@hunter-harness/workflow-harness 0.4.0 → 0.4.2
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/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/pi/.harness-build.json +6 -0
- package/harness/bundles/general/pi/CONTEXT.md +98 -0
- package/harness/bundles/general/pi/README.md +478 -0
- package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/general/pi/harness-plan/SKILL.md +157 -0
- package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/general/pi/harness-review/SKILL.md +166 -0
- package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/general/pi/harness-review/reference.md +114 -0
- package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
- package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
- package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/general/pi/harness-run/reference.md +886 -0
- package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
- package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
- package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
- package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/general/pi/harness-test/reference.md +880 -0
- package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/pi/.harness-build.json +6 -0
- package/harness/bundles/java/pi/CONTEXT.md +98 -0
- package/harness/bundles/java/pi/README.md +478 -0
- package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
- package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
- package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
- package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
- package/harness/bundles/java/pi/harness-package/reference.md +210 -0
- package/harness/bundles/java/pi/harness-plan/SKILL.md +157 -0
- package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/java/pi/harness-review/SKILL.md +166 -0
- package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/java/pi/harness-review/reference.md +114 -0
- package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
- package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
- package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/java/pi/harness-run/reference.md +820 -0
- package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
- package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
- package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
- package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
- package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/java/pi/harness-test/reference.md +792 -0
- package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/manifests/general/claude-code.json +17 -17
- package/harness/manifests/general/codebuddy.json +17 -17
- package/harness/manifests/general/codex.json +17 -17
- package/harness/manifests/general/cursor.json +17 -17
- package/harness/manifests/general/pi.json +475 -0
- package/harness/manifests/java/claude-code.json +19 -19
- package/harness/manifests/java/codebuddy.json +19 -19
- package/harness/manifests/java/codex.json +19 -19
- package/harness/manifests/java/cursor.json +19 -19
- package/harness/manifests/java/pi.json +499 -0
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,2243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve Harness runtimes into a reusable, argv-based phase capsule."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import contextlib
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import fnmatch
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
28
|
+
|
|
29
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
30
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
31
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
32
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
RUN_SESSION_SCHEMA_VERSION = 1
|
|
36
|
+
RUN_TERMINAL_STATUSES = {"OK", "FAIL", "INCOMPLETE", "CANCELLED"}
|
|
37
|
+
_CHANGE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
38
|
+
SECRET_SCAN_RULES_VERSION = "secret-scan-v2"
|
|
39
|
+
SECRET_SCAN_RECEIPT_REL = Path("meta") / "secret-scan-receipt.json"
|
|
40
|
+
# 变更目录里只有一部分内容会被发布:归档包成员是 reports/final/summary-data.json、
|
|
41
|
+
# spec/**.md、plans/**.md、candidates/knowledge.json、meta/archive-meta.md、
|
|
42
|
+
# meta/change-context.json。runtime/ 是各阶段的草稿(review 的 diff、临时校验
|
|
43
|
+
# 脚本、命令输出重定向),从不入包——拿它挡发布,挡的是永远不会离开本机的字节。
|
|
44
|
+
PUBLICATION_EXCLUDED_DIRS: tuple[str, ...] = ("runtime",)
|
|
45
|
+
_SENSITIVE_ASSIGNMENT = re.compile(
|
|
46
|
+
rb"(?i)[\"']?(?:password|passwd|token|secret|cookie|database[_-]?url)"
|
|
47
|
+
rb"[\"']?\s*[:=]\s*[\"']?(?P<value>[^\s\"']{4,})"
|
|
48
|
+
)
|
|
49
|
+
_AUTHORIZATION_ASSIGNMENT = re.compile(
|
|
50
|
+
rb"(?i)[\"']?authorization[\"']?\s*[:=]\s*[\"']?"
|
|
51
|
+
rb"(?:(?:bearer|basic)\s+)?(?P<value>[^\s\"']{4,})"
|
|
52
|
+
)
|
|
53
|
+
_ADAPTERS = {
|
|
54
|
+
"claude-code": (".worktrees", "harness/"),
|
|
55
|
+
"codex": (".worktrees", "harness/"),
|
|
56
|
+
"cursor": (".worktrees", "harness/"),
|
|
57
|
+
"codebuddy": (".worktrees", "harness/"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def now_iso() -> str:
|
|
62
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
|
|
66
|
+
ended_at = now_iso()
|
|
67
|
+
started_at = str(receipt.get("stageStartedAt") or ended_at)
|
|
68
|
+
try:
|
|
69
|
+
started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
70
|
+
ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
|
71
|
+
wall_ms = max(0, int((ended - started).total_seconds() * 1000))
|
|
72
|
+
except ValueError:
|
|
73
|
+
wall_ms = 0
|
|
74
|
+
stage = str(receipt.get("stage") or "unknown")
|
|
75
|
+
timings = receipt.setdefault("stageTimings", [])
|
|
76
|
+
if isinstance(timings, list):
|
|
77
|
+
timings.append(
|
|
78
|
+
{
|
|
79
|
+
"stage": stage,
|
|
80
|
+
"createdAt": started_at,
|
|
81
|
+
"endedAt": ended_at,
|
|
82
|
+
"wallClockMs": wall_ms,
|
|
83
|
+
"activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
|
|
84
|
+
"resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
receipt["stage"] = next_stage
|
|
88
|
+
receipt["stageStartedAt"] = ended_at
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _close_current_stage(receipt: dict[str, Any]) -> None:
|
|
92
|
+
current = str(receipt.get("stage") or "record")
|
|
93
|
+
_stage_transition(receipt, current)
|
|
94
|
+
timings = receipt.get("stageTimings")
|
|
95
|
+
if isinstance(timings, list) and timings:
|
|
96
|
+
receipt["stageStartedAt"] = timings[-1].get("endedAt")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
102
|
+
fd, raw_tmp = tempfile.mkstemp(
|
|
103
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
104
|
+
)
|
|
105
|
+
tmp = Path(raw_tmp)
|
|
106
|
+
try:
|
|
107
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
108
|
+
handle.write(text)
|
|
109
|
+
handle.flush()
|
|
110
|
+
os.fsync(handle.fileno())
|
|
111
|
+
# Windows can transiently deny replacement while another process has
|
|
112
|
+
# just finished reading the receipt. Keep the atomic replace contract,
|
|
113
|
+
# but retry the sharing violation within a small bounded window.
|
|
114
|
+
for attempt in range(20):
|
|
115
|
+
try:
|
|
116
|
+
os.replace(tmp, path)
|
|
117
|
+
break
|
|
118
|
+
except PermissionError:
|
|
119
|
+
if attempt == 19:
|
|
120
|
+
raise
|
|
121
|
+
time.sleep(0.01 * (attempt + 1))
|
|
122
|
+
finally:
|
|
123
|
+
tmp.unlink(missing_ok=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _sha256_json(value: Any) -> str:
|
|
127
|
+
encoded = json.dumps(
|
|
128
|
+
value,
|
|
129
|
+
ensure_ascii=False,
|
|
130
|
+
sort_keys=True,
|
|
131
|
+
separators=(",", ":"),
|
|
132
|
+
).encode("utf-8")
|
|
133
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_excluded(relative: str, exclude_dirs: Sequence[str]) -> bool:
|
|
137
|
+
"""Path is inside one of the excluded top-level directories."""
|
|
138
|
+
head = relative.split("/", 1)[0]
|
|
139
|
+
return head in exclude_dirs
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _publishable_tree_digest(
|
|
143
|
+
root: Path, *, exclude_dirs: Sequence[str] = ()
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Hash publishable bytes while excluding the self-referential scan receipt."""
|
|
146
|
+
root = root.expanduser().resolve()
|
|
147
|
+
digest = hashlib.sha256()
|
|
148
|
+
receipt = (root / SECRET_SCAN_RECEIPT_REL).resolve()
|
|
149
|
+
if not root.is_dir():
|
|
150
|
+
raise OSError(f"publishable evidence root not found: {root}")
|
|
151
|
+
for path in sorted(root.rglob("*")):
|
|
152
|
+
if not path.is_file() or path.resolve() == receipt:
|
|
153
|
+
continue
|
|
154
|
+
if exclude_dirs and _is_excluded(
|
|
155
|
+
path.relative_to(root).as_posix(), exclude_dirs
|
|
156
|
+
):
|
|
157
|
+
continue
|
|
158
|
+
relative = path.relative_to(root).as_posix()
|
|
159
|
+
data = path.read_bytes()
|
|
160
|
+
digest.update(relative.encode("utf-8"))
|
|
161
|
+
digest.update(b"\0")
|
|
162
|
+
digest.update(str(len(data)).encode("ascii"))
|
|
163
|
+
digest.update(b"\0")
|
|
164
|
+
digest.update(hashlib.sha256(data).digest())
|
|
165
|
+
digest.update(b"\0")
|
|
166
|
+
return "sha256:" + digest.hexdigest()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _sensitive_evidence_receipt_path(change_root: Path) -> Path:
|
|
170
|
+
return change_root.expanduser().resolve() / SECRET_SCAN_RECEIPT_REL
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _private_evidence_root(explicit: Path | None = None) -> Path:
|
|
174
|
+
configured = os.environ.get("HARNESS_PRIVATE_EVIDENCE_ROOT")
|
|
175
|
+
return (explicit if explicit is not None else Path(configured) if configured else
|
|
176
|
+
Path.home() / ".harness" / "private-evidence").expanduser().resolve()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def private_evidence_root_for(source: Path, explicit: Path | None = None) -> Path:
|
|
180
|
+
"""私有隔离根:必须与源同一驱动器。
|
|
181
|
+
|
|
182
|
+
`os.replace` 跨驱动器会直接失败(Windows 上是 WinError 17)。默认根
|
|
183
|
+
`~/.harness/private-evidence` 在 Windows 落 C 盘,项目在别的盘时隔离必然失败
|
|
184
|
+
——而隔离是归档的硬前置,等于整条归档路被堵死。同盘时沿用配置/默认值;不同盘
|
|
185
|
+
时退到源所在驱动器根下的 `.harness-private-evidence`(天然在任何项目根之外)。
|
|
186
|
+
POSIX 上 drive 恒为空串,行为不变。
|
|
187
|
+
"""
|
|
188
|
+
source = source.expanduser().resolve()
|
|
189
|
+
preferred = _private_evidence_root(explicit)
|
|
190
|
+
if preferred.drive.lower() == source.drive.lower():
|
|
191
|
+
return preferred
|
|
192
|
+
return (Path(source.anchor) / ".harness-private-evidence").resolve()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _infer_project_root(change_root: Path) -> Path | None:
|
|
196
|
+
"""change 目录形如 `<project>/.harness/changes/<name>`:取路径里 `.harness` 段的父目录。
|
|
197
|
+
|
|
198
|
+
不做"向上找存在 .harness 的目录"式搜索——用户 HOME 下通常也有 `.harness`,
|
|
199
|
+
那样会把 HOME 误判成项目根,把合法的临时隔离位置一并拒掉。
|
|
200
|
+
"""
|
|
201
|
+
parts = change_root.parts
|
|
202
|
+
for index in range(len(parts) - 1, 0, -1):
|
|
203
|
+
if parts[index] == ".harness":
|
|
204
|
+
return Path(*parts[:index])
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _secure_private_path(path: Path, *, directory: bool) -> str:
|
|
209
|
+
"""Best-effort owner-only permissions, with an explicit ACL result on Windows."""
|
|
210
|
+
mode = 0o700 if directory else 0o600
|
|
211
|
+
try:
|
|
212
|
+
path.chmod(mode)
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
if os.name != "nt":
|
|
216
|
+
return "POSIX_OWNER_ONLY"
|
|
217
|
+
try:
|
|
218
|
+
account = os.environ.get("USERNAME") or os.environ.get("USER")
|
|
219
|
+
if not account:
|
|
220
|
+
account = subprocess.check_output(
|
|
221
|
+
["whoami"], stderr=subprocess.DEVNULL, text=True, encoding="utf-8"
|
|
222
|
+
).strip()
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{account}:F"],
|
|
225
|
+
capture_output=True,
|
|
226
|
+
text=True,
|
|
227
|
+
encoding="utf-8",
|
|
228
|
+
errors="replace",
|
|
229
|
+
check=False,
|
|
230
|
+
timeout=10,
|
|
231
|
+
)
|
|
232
|
+
return "WINDOWS_CURRENT_USER_ONLY" if result.returncode == 0 else "WINDOWS_ACL_UNVERIFIED"
|
|
233
|
+
except (OSError, subprocess.SubprocessError):
|
|
234
|
+
return "WINDOWS_ACL_UNVERIFIED"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# runtime/ 下哪些是**过程草稿**。白名单式判断:只删明确列举的形态,
|
|
238
|
+
# 其余一律保留——门禁、租约、恢复和证据都住在这棵树里,宁可留垃圾也不能误删。
|
|
239
|
+
SCRATCH_PATTERNS: tuple[str, ...] = (
|
|
240
|
+
"*.patch",
|
|
241
|
+
"*.diff",
|
|
242
|
+
"*-input.json",
|
|
243
|
+
"*-input[0-9].json",
|
|
244
|
+
"findings-rereview.json",
|
|
245
|
+
"archive-out*.json",
|
|
246
|
+
"fixback-*-check.*",
|
|
247
|
+
)
|
|
248
|
+
# 即便命中上面的形态也必须留下的名字:它们是运行态,不是草稿。
|
|
249
|
+
SCRATCH_KEEP: frozenset[str] = frozenset({
|
|
250
|
+
"context-lease.json",
|
|
251
|
+
"fixback-session.json",
|
|
252
|
+
"preflight.json",
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def sweep_scratch(change_root: Path, *, dry_run: bool = False) -> dict[str, Any]:
|
|
257
|
+
"""清掉 runtime/ 顶层的过程草稿,保留运行态、会话、证据与报告。
|
|
258
|
+
|
|
259
|
+
一轮流程下来 runtime/ 会堆满 diff、临时校验脚本和命令输出重定向。没人清,
|
|
260
|
+
而且它们会被其他门禁读到——2026-08-19 那次,review 自己生成的两个 diff
|
|
261
|
+
直接把归档挡住了。
|
|
262
|
+
|
|
263
|
+
只扫 runtime/ 顶层文件:run-sessions/ 等子目录是托管会话证据,不进扫描。
|
|
264
|
+
"""
|
|
265
|
+
change_root = change_root.expanduser().resolve()
|
|
266
|
+
runtime_dir = change_root / "runtime"
|
|
267
|
+
removed: list[str] = []
|
|
268
|
+
if not runtime_dir.is_dir():
|
|
269
|
+
return {"ok": True, "code": "SCRATCH_SWEPT", "removed": [], "dryRun": dry_run}
|
|
270
|
+
errors: list[dict[str, str]] = []
|
|
271
|
+
for path in sorted(runtime_dir.iterdir()):
|
|
272
|
+
if not path.is_file() or path.name in SCRATCH_KEEP:
|
|
273
|
+
continue
|
|
274
|
+
if not any(fnmatch.fnmatch(path.name, pattern) for pattern in SCRATCH_PATTERNS):
|
|
275
|
+
continue
|
|
276
|
+
relative = path.relative_to(change_root).as_posix()
|
|
277
|
+
if dry_run:
|
|
278
|
+
removed.append(relative)
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
path.unlink()
|
|
282
|
+
except OSError as exc:
|
|
283
|
+
errors.append({"path": relative, "error": str(exc)})
|
|
284
|
+
continue
|
|
285
|
+
removed.append(relative)
|
|
286
|
+
return {
|
|
287
|
+
"ok": not errors,
|
|
288
|
+
"code": "SCRATCH_SWEPT" if not errors else "SCRATCH_SWEEP_PARTIAL",
|
|
289
|
+
"removed": removed,
|
|
290
|
+
"errors": errors,
|
|
291
|
+
"dryRun": dry_run,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _sensitive_candidates(
|
|
296
|
+
root: Path, *, exclude_dirs: Sequence[str] = ()
|
|
297
|
+
) -> list[dict[str, Any]]:
|
|
298
|
+
"""Find high-confidence plaintext assignments without treating prose as a secret.
|
|
299
|
+
|
|
300
|
+
``exclude_dirs`` scopes the scan to what a caller actually publishes. The
|
|
301
|
+
default stays whole-tree: the quarantine flow depends on seeing legacy
|
|
302
|
+
plaintext anywhere under the change, including scratch directories.
|
|
303
|
+
"""
|
|
304
|
+
# Compare by relative path, not by resolve() per file: this loop covers the
|
|
305
|
+
# whole change tree, and on Windows each resolve() is a realpath syscall.
|
|
306
|
+
receipt_rel = SECRET_SCAN_RECEIPT_REL.as_posix()
|
|
307
|
+
candidates: list[dict[str, Any]] = []
|
|
308
|
+
for path in sorted(root.rglob("*")):
|
|
309
|
+
if not path.is_file():
|
|
310
|
+
continue
|
|
311
|
+
relative = path.relative_to(root).as_posix()
|
|
312
|
+
if relative == receipt_rel:
|
|
313
|
+
continue
|
|
314
|
+
if exclude_dirs and _is_excluded(relative, exclude_dirs):
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
raw = path.read_bytes()
|
|
318
|
+
except OSError:
|
|
319
|
+
continue
|
|
320
|
+
finding = next(
|
|
321
|
+
(
|
|
322
|
+
match
|
|
323
|
+
for pattern in (_SENSITIVE_ASSIGNMENT, _AUTHORIZATION_ASSIGNMENT)
|
|
324
|
+
for match in pattern.finditer(raw)
|
|
325
|
+
if not _is_sensitive_placeholder(match.group("value"))
|
|
326
|
+
),
|
|
327
|
+
None,
|
|
328
|
+
)
|
|
329
|
+
if finding is None:
|
|
330
|
+
continue
|
|
331
|
+
candidates.append({
|
|
332
|
+
"path": path.relative_to(root).as_posix(),
|
|
333
|
+
"reasonCode": "PLAINTEXT_SENSITIVE_ASSIGNMENT",
|
|
334
|
+
"digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
335
|
+
})
|
|
336
|
+
return candidates
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _is_sensitive_placeholder(value: bytes) -> bool:
|
|
340
|
+
text = value.decode("utf-8", errors="ignore").strip().strip("`'").strip()
|
|
341
|
+
return bool(
|
|
342
|
+
re.fullmatch(r"<[^<>]+>", text)
|
|
343
|
+
or re.fullmatch(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}", text)
|
|
344
|
+
or re.fullmatch(r"\{\{[^{}]+\}\}", text)
|
|
345
|
+
or re.fullmatch(
|
|
346
|
+
r"(?i)(?:example|placeholder|redacted|masked|bearer|basic|"
|
|
347
|
+
r"your[-_][A-Za-z0-9_-]+|x{4,})",
|
|
348
|
+
text,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def publishable_tree_digest(
|
|
354
|
+
root: Path, *, exclude_dirs: Sequence[str] = ()
|
|
355
|
+
) -> str:
|
|
356
|
+
"""Public digest helper shared by the archive publication gate."""
|
|
357
|
+
return _publishable_tree_digest(root, exclude_dirs=exclude_dirs)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def sensitive_evidence_receipt_path(change_root: Path) -> Path:
|
|
361
|
+
return _sensitive_evidence_receipt_path(change_root)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def sensitive_evidence_candidates(
|
|
365
|
+
root: Path, *, exclude_dirs: Sequence[str] = ()
|
|
366
|
+
) -> list[dict[str, Any]]:
|
|
367
|
+
return _sensitive_candidates(root, exclude_dirs=exclude_dirs)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _build_sensitive_scan_receipt(
|
|
371
|
+
change_root: Path,
|
|
372
|
+
*,
|
|
373
|
+
entries: list[dict[str, Any]],
|
|
374
|
+
unresolved: list[dict[str, Any]],
|
|
375
|
+
status: str,
|
|
376
|
+
exclude_dirs: Sequence[str] = (),
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
change_root = change_root.expanduser().resolve()
|
|
379
|
+
return {
|
|
380
|
+
"schemaVersion": 1,
|
|
381
|
+
"rulesVersion": SECRET_SCAN_RULES_VERSION,
|
|
382
|
+
"changeId": change_root.name,
|
|
383
|
+
"status": status,
|
|
384
|
+
"unresolvedFailures": unresolved,
|
|
385
|
+
"entries": entries,
|
|
386
|
+
"publishableTreeDigest": _publishable_tree_digest(
|
|
387
|
+
change_root, exclude_dirs=exclude_dirs
|
|
388
|
+
),
|
|
389
|
+
# 收据自述扫描范围:复算摘要的一方据此排除同样的目录,
|
|
390
|
+
# 否则两侧口径不同会误报 digest 漂移。
|
|
391
|
+
"publicationExcludedDirs": list(exclude_dirs),
|
|
392
|
+
"publicationExcluded": True,
|
|
393
|
+
"recordedAt": now_iso(),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _write_sensitive_scan_receipt(
|
|
398
|
+
change_root: Path,
|
|
399
|
+
*,
|
|
400
|
+
entries: list[dict[str, Any]],
|
|
401
|
+
unresolved: list[dict[str, Any]],
|
|
402
|
+
status: str,
|
|
403
|
+
exclude_dirs: Sequence[str] = (),
|
|
404
|
+
) -> dict[str, Any]:
|
|
405
|
+
change_root = change_root.expanduser().resolve()
|
|
406
|
+
receipt = _build_sensitive_scan_receipt(
|
|
407
|
+
change_root,
|
|
408
|
+
entries=entries,
|
|
409
|
+
unresolved=unresolved,
|
|
410
|
+
status=status,
|
|
411
|
+
exclude_dirs=exclude_dirs,
|
|
412
|
+
)
|
|
413
|
+
atomic_write_json(_sensitive_evidence_receipt_path(change_root), receipt)
|
|
414
|
+
return receipt
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def quarantine_sensitive_evidence(
|
|
418
|
+
source: Path,
|
|
419
|
+
*,
|
|
420
|
+
change_root: Path,
|
|
421
|
+
reason: str = "sensitive evidence",
|
|
422
|
+
private_root: Path | None = None,
|
|
423
|
+
project_root: Path | None = None,
|
|
424
|
+
) -> dict[str, Any]:
|
|
425
|
+
"""Atomically move legacy plaintext evidence outside any publishable tree."""
|
|
426
|
+
source = source.expanduser().resolve()
|
|
427
|
+
change_root = change_root.expanduser().resolve()
|
|
428
|
+
private = private_evidence_root_for(source, private_root)
|
|
429
|
+
if not source.is_file():
|
|
430
|
+
return {"ok": False, "reasonCode": "SENSITIVE_EVIDENCE_SOURCE_MISSING", "sourcePath": str(source)}
|
|
431
|
+
if private == change_root or private.is_relative_to(change_root):
|
|
432
|
+
return {
|
|
433
|
+
"ok": False,
|
|
434
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
435
|
+
"error": "private quarantine root must be outside the publishable change root",
|
|
436
|
+
}
|
|
437
|
+
# 归档的密钥扫描门禁按**项目根**判定(SECRET_SCAN_PRIVATE_PATH_IN_COPY_ROOT)。
|
|
438
|
+
# 这里此前只看 change_root,比门禁宽——于是隔离"成功"了,门禁再拒一次,调用方
|
|
439
|
+
# 得多试一轮才找到合法位置。对齐到同一条边界,错就错在当场。
|
|
440
|
+
resolved_project = (
|
|
441
|
+
project_root.expanduser().resolve()
|
|
442
|
+
if project_root is not None
|
|
443
|
+
else _infer_project_root(change_root)
|
|
444
|
+
)
|
|
445
|
+
if resolved_project is not None and (
|
|
446
|
+
private == resolved_project or private.is_relative_to(resolved_project)
|
|
447
|
+
):
|
|
448
|
+
return {
|
|
449
|
+
"ok": False,
|
|
450
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
451
|
+
"error": (
|
|
452
|
+
"private quarantine root must be outside the project root "
|
|
453
|
+
f"({resolved_project}); archive's secret scan rejects paths inside it"
|
|
454
|
+
),
|
|
455
|
+
}
|
|
456
|
+
try:
|
|
457
|
+
raw_digest = "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest()
|
|
458
|
+
quarantine_dir = private / uuid.uuid4().hex
|
|
459
|
+
quarantine_dir.mkdir(parents=True, exist_ok=False)
|
|
460
|
+
acl_dir = _secure_private_path(quarantine_dir, directory=True)
|
|
461
|
+
target = quarantine_dir / "payload.bin"
|
|
462
|
+
os.replace(source, target)
|
|
463
|
+
acl_file = _secure_private_path(target, directory=False)
|
|
464
|
+
if os.name == "nt" and (
|
|
465
|
+
acl_dir != "WINDOWS_CURRENT_USER_ONLY"
|
|
466
|
+
or acl_file != "WINDOWS_CURRENT_USER_ONLY"
|
|
467
|
+
):
|
|
468
|
+
os.replace(target, source)
|
|
469
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
470
|
+
return {
|
|
471
|
+
"ok": False,
|
|
472
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
473
|
+
"error": "private quarantine ACL could not be restricted to the current user",
|
|
474
|
+
}
|
|
475
|
+
moved_digest = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
|
|
476
|
+
if moved_digest != raw_digest:
|
|
477
|
+
os.replace(target, source)
|
|
478
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
479
|
+
return {
|
|
480
|
+
"ok": False,
|
|
481
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
482
|
+
"error": "quarantine digest verification failed",
|
|
483
|
+
}
|
|
484
|
+
try:
|
|
485
|
+
prior = json.loads(
|
|
486
|
+
_sensitive_evidence_receipt_path(change_root).read_text(
|
|
487
|
+
encoding="utf-8-sig"
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
except (OSError, json.JSONDecodeError):
|
|
491
|
+
prior = {}
|
|
492
|
+
entries = list(prior.get("entries") or []) if isinstance(prior, dict) else []
|
|
493
|
+
entries.append({
|
|
494
|
+
"sourcePath": (
|
|
495
|
+
source.relative_to(change_root).as_posix()
|
|
496
|
+
if source.is_relative_to(change_root)
|
|
497
|
+
else source.name
|
|
498
|
+
),
|
|
499
|
+
"sourceDigest": raw_digest,
|
|
500
|
+
"privatePath": str(target),
|
|
501
|
+
"reason": reason[:160],
|
|
502
|
+
"status": "QUARANTINED",
|
|
503
|
+
"acl": {"directory": acl_dir, "file": acl_file},
|
|
504
|
+
"movedAt": now_iso(),
|
|
505
|
+
})
|
|
506
|
+
try:
|
|
507
|
+
receipt = _write_sensitive_scan_receipt(
|
|
508
|
+
change_root,
|
|
509
|
+
entries=entries,
|
|
510
|
+
unresolved=[],
|
|
511
|
+
status="QUARANTINED",
|
|
512
|
+
)
|
|
513
|
+
except BaseException as exc:
|
|
514
|
+
try:
|
|
515
|
+
os.replace(target, source)
|
|
516
|
+
except OSError:
|
|
517
|
+
pass
|
|
518
|
+
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
519
|
+
return {
|
|
520
|
+
"ok": False,
|
|
521
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
522
|
+
"error": str(exc),
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
"ok": True,
|
|
526
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINED",
|
|
527
|
+
"sourceDigest": raw_digest,
|
|
528
|
+
"privatePath": str(target),
|
|
529
|
+
"receiptPath": str(_sensitive_evidence_receipt_path(change_root)),
|
|
530
|
+
"receipt": receipt,
|
|
531
|
+
}
|
|
532
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
533
|
+
# No copy is retained on failure. If os.replace crossed filesystems it
|
|
534
|
+
# fails before removing the source, which is the required fail-closed path.
|
|
535
|
+
return {
|
|
536
|
+
"ok": False,
|
|
537
|
+
"reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
538
|
+
"error": str(exc),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def ensure_sensitive_evidence_scan_receipt(change_root: Path) -> dict[str, Any]:
|
|
543
|
+
"""Create a digest-bound scan receipt for a tree with no plaintext findings."""
|
|
544
|
+
change_root = change_root.expanduser().resolve()
|
|
545
|
+
unresolved = _sensitive_candidates(change_root)
|
|
546
|
+
if unresolved:
|
|
547
|
+
return _write_sensitive_scan_receipt(
|
|
548
|
+
change_root,
|
|
549
|
+
entries=[],
|
|
550
|
+
unresolved=unresolved,
|
|
551
|
+
status="FAIL",
|
|
552
|
+
)
|
|
553
|
+
return _write_sensitive_scan_receipt(
|
|
554
|
+
change_root,
|
|
555
|
+
entries=[],
|
|
556
|
+
unresolved=[],
|
|
557
|
+
status="OK",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def refresh_sensitive_evidence_scan_receipt(
|
|
562
|
+
change_root: Path,
|
|
563
|
+
*,
|
|
564
|
+
persist: bool = True,
|
|
565
|
+
exclude_dirs: Sequence[str] = (),
|
|
566
|
+
) -> dict[str, Any]:
|
|
567
|
+
"""Rescan current bytes while preserving valid quarantine audit entries."""
|
|
568
|
+
change_root = change_root.expanduser().resolve()
|
|
569
|
+
receipt_path = _sensitive_evidence_receipt_path(change_root)
|
|
570
|
+
entries: list[dict[str, Any]] = []
|
|
571
|
+
if receipt_path.is_file():
|
|
572
|
+
try:
|
|
573
|
+
prior = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
574
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
575
|
+
return {
|
|
576
|
+
"ok": False,
|
|
577
|
+
"reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
|
|
578
|
+
"receiptPath": str(receipt_path),
|
|
579
|
+
"error": str(exc),
|
|
580
|
+
}
|
|
581
|
+
if not isinstance(prior, dict):
|
|
582
|
+
return {
|
|
583
|
+
"ok": False,
|
|
584
|
+
"reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
|
|
585
|
+
"receiptPath": str(receipt_path),
|
|
586
|
+
"error": "receipt must be an object",
|
|
587
|
+
}
|
|
588
|
+
prior_entries = prior.get("entries")
|
|
589
|
+
prior_unresolved = prior.get("unresolvedFailures")
|
|
590
|
+
valid_identity = (
|
|
591
|
+
prior.get("schemaVersion") == 1
|
|
592
|
+
and prior.get("rulesVersion") == SECRET_SCAN_RULES_VERSION
|
|
593
|
+
and prior.get("changeId") == change_root.name
|
|
594
|
+
and prior.get("publicationExcluded") is True
|
|
595
|
+
and str(prior.get("status") or "").upper()
|
|
596
|
+
in {"OK", "QUARANTINED", "FAIL"}
|
|
597
|
+
)
|
|
598
|
+
if (
|
|
599
|
+
not valid_identity
|
|
600
|
+
or not isinstance(prior_entries, list)
|
|
601
|
+
or not isinstance(prior_unresolved, list)
|
|
602
|
+
):
|
|
603
|
+
return {
|
|
604
|
+
"ok": False,
|
|
605
|
+
"reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
|
|
606
|
+
"receiptPath": str(receipt_path),
|
|
607
|
+
"error": "receipt identity or collection fields are invalid",
|
|
608
|
+
}
|
|
609
|
+
entries = list(prior_entries)
|
|
610
|
+
|
|
611
|
+
unresolved = _sensitive_candidates(change_root, exclude_dirs=exclude_dirs)
|
|
612
|
+
status = "FAIL" if unresolved else "QUARANTINED" if entries else "OK"
|
|
613
|
+
if persist:
|
|
614
|
+
try:
|
|
615
|
+
receipt = _write_sensitive_scan_receipt(
|
|
616
|
+
change_root,
|
|
617
|
+
entries=entries,
|
|
618
|
+
unresolved=unresolved,
|
|
619
|
+
status=status,
|
|
620
|
+
exclude_dirs=exclude_dirs,
|
|
621
|
+
)
|
|
622
|
+
except OSError as exc:
|
|
623
|
+
return {
|
|
624
|
+
"ok": False,
|
|
625
|
+
"reasonCode": "SECRET_SCAN_RECEIPT_REFRESH_FAILED",
|
|
626
|
+
"receiptPath": str(receipt_path),
|
|
627
|
+
"error": str(exc),
|
|
628
|
+
}
|
|
629
|
+
else:
|
|
630
|
+
receipt = _build_sensitive_scan_receipt(
|
|
631
|
+
change_root,
|
|
632
|
+
entries=entries,
|
|
633
|
+
unresolved=unresolved,
|
|
634
|
+
status=status,
|
|
635
|
+
exclude_dirs=exclude_dirs,
|
|
636
|
+
)
|
|
637
|
+
return {
|
|
638
|
+
"ok": not unresolved,
|
|
639
|
+
"reasonCode": (
|
|
640
|
+
"SENSITIVE_EVIDENCE_UNQUARANTINED"
|
|
641
|
+
if unresolved
|
|
642
|
+
else "SECRET_SCAN_RECEIPT_REFRESHED"
|
|
643
|
+
),
|
|
644
|
+
"receiptPath": str(receipt_path),
|
|
645
|
+
"receipt": receipt,
|
|
646
|
+
"persisted": persist,
|
|
647
|
+
"unresolvedFailures": unresolved,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _run_sessions_root(state_root: Path) -> Path:
|
|
652
|
+
return state_root.expanduser().resolve() / "runtime" / "run-sessions"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _run_session_root(state_root: Path, session_id: str) -> Path:
|
|
656
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
|
|
657
|
+
raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
|
|
658
|
+
return _run_sessions_root(state_root) / session_id
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _run_receipt_path(state_root: Path, session_id: str) -> Path:
|
|
662
|
+
return _run_session_root(state_root, session_id) / "session.json"
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _resource_lock_secrets_path(state_root: Path, session_id: str) -> Path:
|
|
666
|
+
return _run_session_root(state_root, session_id) / "resource-lock-secrets.json"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
670
|
+
path = _run_receipt_path(state_root, session_id)
|
|
671
|
+
last_error: OSError | None = None
|
|
672
|
+
for attempt in range(20):
|
|
673
|
+
try:
|
|
674
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
675
|
+
break
|
|
676
|
+
except FileNotFoundError as exc:
|
|
677
|
+
raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
|
|
678
|
+
except PermissionError as exc:
|
|
679
|
+
last_error = exc
|
|
680
|
+
if attempt == 19:
|
|
681
|
+
raise ValueError(
|
|
682
|
+
f"RUN_SESSION_RECEIPT_BUSY: {session_id}"
|
|
683
|
+
) from exc
|
|
684
|
+
time.sleep(0.01 * (attempt + 1))
|
|
685
|
+
else:
|
|
686
|
+
raise ValueError(f"RUN_SESSION_RECEIPT_BUSY: {session_id}") from last_error
|
|
687
|
+
if not isinstance(value, dict):
|
|
688
|
+
raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
|
|
689
|
+
return value
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _load_resource_lock_tokens(
|
|
693
|
+
state_root: Path,
|
|
694
|
+
session_id: str,
|
|
695
|
+
receipt: dict[str, Any],
|
|
696
|
+
) -> dict[str, str]:
|
|
697
|
+
"""Load lock tokens from a private sidecar; legacy receipts may inline them."""
|
|
698
|
+
path = _resource_lock_secrets_path(state_root, session_id)
|
|
699
|
+
try:
|
|
700
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
701
|
+
if isinstance(value, dict) and isinstance(value.get("tokens"), dict):
|
|
702
|
+
return {
|
|
703
|
+
str(name): str(token)
|
|
704
|
+
for name, token in value["tokens"].items()
|
|
705
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
706
|
+
}
|
|
707
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
708
|
+
pass
|
|
709
|
+
legacy = receipt.get("resourceLockTokens")
|
|
710
|
+
if isinstance(legacy, dict):
|
|
711
|
+
return {
|
|
712
|
+
str(name): str(token)
|
|
713
|
+
for name, token in legacy.items()
|
|
714
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
715
|
+
}
|
|
716
|
+
return {}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _receipt_identity_state(
|
|
720
|
+
receipt: dict[str, Any],
|
|
721
|
+
*,
|
|
722
|
+
field: str = "workerIdentity",
|
|
723
|
+
) -> bool | None:
|
|
724
|
+
"""Verify a canonical provider attestation, with legacy facade fallback."""
|
|
725
|
+
identity = receipt.get(field)
|
|
726
|
+
pid = identity.get("pid") if isinstance(identity, dict) else receipt.get("workerPid")
|
|
727
|
+
if isinstance(identity, dict) and {
|
|
728
|
+
"schemaVersion",
|
|
729
|
+
"createdAt",
|
|
730
|
+
"fieldProvenance",
|
|
731
|
+
"capabilities",
|
|
732
|
+
} <= set(identity):
|
|
733
|
+
from harness_process import observe_process_identity, verify_process_identity
|
|
734
|
+
|
|
735
|
+
observed = observe_process_identity(pid)
|
|
736
|
+
decision = verify_process_identity(identity, observed)
|
|
737
|
+
if decision.get("ok") is True:
|
|
738
|
+
return True
|
|
739
|
+
if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
|
|
740
|
+
return None
|
|
741
|
+
return False
|
|
742
|
+
from harness_service import verify_process_identity
|
|
743
|
+
|
|
744
|
+
legacy = {
|
|
745
|
+
"pid": pid,
|
|
746
|
+
"startedAt": (
|
|
747
|
+
identity.get("startedAt")
|
|
748
|
+
if isinstance(identity, dict)
|
|
749
|
+
else None
|
|
750
|
+
),
|
|
751
|
+
"processIdentity": {
|
|
752
|
+
"executable": (
|
|
753
|
+
identity.get("executable")
|
|
754
|
+
if isinstance(identity, dict)
|
|
755
|
+
else None
|
|
756
|
+
)
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
return verify_process_identity(legacy)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _capture_provider_members(spawned: Any) -> None:
|
|
763
|
+
"""Persist current provider-owned members before a later cleanup."""
|
|
764
|
+
proof = getattr(spawned, "ownershipProof", None)
|
|
765
|
+
leader = getattr(spawned, "attestation", None)
|
|
766
|
+
if not isinstance(proof, dict) or not isinstance(leader, dict):
|
|
767
|
+
return
|
|
768
|
+
from harness_process import capture_owned_members
|
|
769
|
+
|
|
770
|
+
capture_owned_members(spawned)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _resource_lock_root() -> Path:
|
|
774
|
+
return Path(
|
|
775
|
+
os.environ.get(
|
|
776
|
+
"HARNESS_RESOURCE_LOCK_ROOT",
|
|
777
|
+
str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
|
|
778
|
+
)
|
|
779
|
+
).expanduser().resolve()
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
|
|
783
|
+
digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
|
|
784
|
+
return _resource_lock_root() / f"{digest}.json"
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _lock_owner_identity(owner: Any) -> bool | None:
|
|
788
|
+
if not isinstance(owner, dict):
|
|
789
|
+
return None
|
|
790
|
+
pid = owner.get("pid")
|
|
791
|
+
started_at = owner.get("startedAt")
|
|
792
|
+
executable = owner.get("executable")
|
|
793
|
+
if (
|
|
794
|
+
not isinstance(pid, int)
|
|
795
|
+
or pid <= 0
|
|
796
|
+
or not isinstance(started_at, str)
|
|
797
|
+
or not started_at
|
|
798
|
+
or not isinstance(executable, str)
|
|
799
|
+
or not executable
|
|
800
|
+
):
|
|
801
|
+
return None
|
|
802
|
+
from harness_service import verify_process_identity
|
|
803
|
+
|
|
804
|
+
return verify_process_identity(
|
|
805
|
+
{
|
|
806
|
+
"pid": pid,
|
|
807
|
+
"startedAt": started_at,
|
|
808
|
+
"processIdentity": {"executable": executable},
|
|
809
|
+
}
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _valid_resource_lock_record(
|
|
814
|
+
record: Any,
|
|
815
|
+
*,
|
|
816
|
+
lock_name: str,
|
|
817
|
+
) -> bool:
|
|
818
|
+
return bool(
|
|
819
|
+
isinstance(record, dict)
|
|
820
|
+
and record.get("schemaVersion") == 2
|
|
821
|
+
and record.get("lockName") == lock_name
|
|
822
|
+
and isinstance(record.get("sessionId"), str)
|
|
823
|
+
and record.get("sessionId")
|
|
824
|
+
and isinstance(record.get("token"), str)
|
|
825
|
+
and record.get("token")
|
|
826
|
+
and isinstance(record.get("heartbeatAtUnix"), (int, float))
|
|
827
|
+
and isinstance(record.get("expiresAtUnix"), (int, float))
|
|
828
|
+
and _lock_owner_identity(record.get("owner")) is not None
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _write_resource_lock(
|
|
833
|
+
path: Path,
|
|
834
|
+
*,
|
|
835
|
+
lock_name: str,
|
|
836
|
+
session_id: str,
|
|
837
|
+
token: str,
|
|
838
|
+
owner: dict[str, Any],
|
|
839
|
+
acquired_at: str,
|
|
840
|
+
) -> None:
|
|
841
|
+
now = time.time()
|
|
842
|
+
atomic_write_json(
|
|
843
|
+
path,
|
|
844
|
+
{
|
|
845
|
+
"schemaVersion": 2,
|
|
846
|
+
"lockName": lock_name,
|
|
847
|
+
"sessionId": session_id,
|
|
848
|
+
"token": token,
|
|
849
|
+
"acquiredAt": acquired_at,
|
|
850
|
+
"heartbeatAtUnix": now,
|
|
851
|
+
"expiresAtUnix": now + 300.0,
|
|
852
|
+
"owner": owner,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _refresh_resource_locks(
|
|
858
|
+
state_root: Path,
|
|
859
|
+
session_id: str,
|
|
860
|
+
tokens: dict[str, str],
|
|
861
|
+
owner: dict[str, Any],
|
|
862
|
+
) -> bool:
|
|
863
|
+
refreshed = True
|
|
864
|
+
for lock_name, token in sorted(tokens.items()):
|
|
865
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
866
|
+
try:
|
|
867
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
868
|
+
except (OSError, json.JSONDecodeError):
|
|
869
|
+
refreshed = False
|
|
870
|
+
continue
|
|
871
|
+
if (
|
|
872
|
+
not _valid_resource_lock_record(record, lock_name=lock_name)
|
|
873
|
+
or record.get("sessionId") != session_id
|
|
874
|
+
or record.get("token") != token
|
|
875
|
+
):
|
|
876
|
+
refreshed = False
|
|
877
|
+
continue
|
|
878
|
+
_write_resource_lock(
|
|
879
|
+
path,
|
|
880
|
+
lock_name=lock_name,
|
|
881
|
+
session_id=session_id,
|
|
882
|
+
token=token,
|
|
883
|
+
owner=owner,
|
|
884
|
+
acquired_at=str(record["acquiredAt"]),
|
|
885
|
+
)
|
|
886
|
+
return refreshed
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _release_resource_locks(
|
|
890
|
+
state_root: Path,
|
|
891
|
+
session_id: str,
|
|
892
|
+
tokens: dict[str, str],
|
|
893
|
+
) -> bool:
|
|
894
|
+
released = True
|
|
895
|
+
for lock_name, token in sorted(tokens.items()):
|
|
896
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
897
|
+
try:
|
|
898
|
+
record = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
899
|
+
except FileNotFoundError:
|
|
900
|
+
continue
|
|
901
|
+
except (OSError, json.JSONDecodeError):
|
|
902
|
+
released = False
|
|
903
|
+
continue
|
|
904
|
+
if (
|
|
905
|
+
not isinstance(record, dict)
|
|
906
|
+
or record.get("sessionId") != session_id
|
|
907
|
+
or record.get("token") != token
|
|
908
|
+
):
|
|
909
|
+
released = False
|
|
910
|
+
continue
|
|
911
|
+
try:
|
|
912
|
+
path.unlink()
|
|
913
|
+
except OSError:
|
|
914
|
+
released = False
|
|
915
|
+
return released
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _acquire_resource_locks(
|
|
919
|
+
state_root: Path,
|
|
920
|
+
session_id: str,
|
|
921
|
+
lock_names: list[str],
|
|
922
|
+
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
|
923
|
+
tokens: dict[str, str] = {}
|
|
924
|
+
for lock_name in lock_names:
|
|
925
|
+
path = _resource_lock_path(state_root, lock_name)
|
|
926
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
927
|
+
token = uuid.uuid4().hex
|
|
928
|
+
owner_identity = _process_identity(os.getpid(), sys.executable)
|
|
929
|
+
now = time.time()
|
|
930
|
+
record = {
|
|
931
|
+
"schemaVersion": 2,
|
|
932
|
+
"lockName": lock_name,
|
|
933
|
+
"sessionId": session_id,
|
|
934
|
+
"token": token,
|
|
935
|
+
"acquiredAt": now_iso(),
|
|
936
|
+
"heartbeatAtUnix": now,
|
|
937
|
+
"expiresAtUnix": now + 300.0,
|
|
938
|
+
"owner": owner_identity,
|
|
939
|
+
}
|
|
940
|
+
encoded = (
|
|
941
|
+
json.dumps(record, ensure_ascii=False, indent=2) + "\n"
|
|
942
|
+
).encode("utf-8")
|
|
943
|
+
for attempt in range(2):
|
|
944
|
+
try:
|
|
945
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
946
|
+
except FileExistsError:
|
|
947
|
+
owner: dict[str, Any] | None = None
|
|
948
|
+
try:
|
|
949
|
+
loaded = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
950
|
+
owner = loaded if isinstance(loaded, dict) else None
|
|
951
|
+
except (OSError, json.JSONDecodeError):
|
|
952
|
+
owner = None
|
|
953
|
+
reclaimable = bool(
|
|
954
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
955
|
+
and _lock_owner_identity(owner.get("owner")) is False
|
|
956
|
+
)
|
|
957
|
+
if reclaimable and attempt == 0:
|
|
958
|
+
try:
|
|
959
|
+
current = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
960
|
+
if current.get("token") == owner.get("token"):
|
|
961
|
+
path.unlink()
|
|
962
|
+
continue
|
|
963
|
+
except (OSError, json.JSONDecodeError):
|
|
964
|
+
pass
|
|
965
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
966
|
+
return {}, {
|
|
967
|
+
"lockName": lock_name,
|
|
968
|
+
"ownerSessionId": (
|
|
969
|
+
owner.get("sessionId") if owner is not None else None
|
|
970
|
+
),
|
|
971
|
+
"lockRecordValid": bool(
|
|
972
|
+
_valid_resource_lock_record(owner, lock_name=lock_name)
|
|
973
|
+
),
|
|
974
|
+
"ownerIdentity": (
|
|
975
|
+
_lock_owner_identity(owner.get("owner"))
|
|
976
|
+
if owner is not None
|
|
977
|
+
else None
|
|
978
|
+
),
|
|
979
|
+
}
|
|
980
|
+
else:
|
|
981
|
+
break
|
|
982
|
+
else:
|
|
983
|
+
raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
|
|
984
|
+
try:
|
|
985
|
+
with os.fdopen(fd, "wb") as handle:
|
|
986
|
+
handle.write(encoded)
|
|
987
|
+
handle.flush()
|
|
988
|
+
os.fsync(handle.fileno())
|
|
989
|
+
except BaseException:
|
|
990
|
+
path.unlink(missing_ok=True)
|
|
991
|
+
_release_resource_locks(state_root, session_id, tokens)
|
|
992
|
+
raise
|
|
993
|
+
tokens[lock_name] = token
|
|
994
|
+
return tokens, None
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
|
|
998
|
+
_update_receipt_contract(receipt)
|
|
999
|
+
publishable = json.loads(json.dumps(receipt, ensure_ascii=False))
|
|
1000
|
+
raw_tokens = publishable.pop("resourceLockTokens", {})
|
|
1001
|
+
if isinstance(raw_tokens, dict):
|
|
1002
|
+
publishable["resourceLockTokenHashes"] = {
|
|
1003
|
+
str(name): _sha256_json(str(token))
|
|
1004
|
+
for name, token in raw_tokens.items()
|
|
1005
|
+
if isinstance(name, str) and isinstance(token, str)
|
|
1006
|
+
}
|
|
1007
|
+
atomic_write_json(
|
|
1008
|
+
_run_receipt_path(state_root, str(receipt["sessionId"])),
|
|
1009
|
+
publishable,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
|
|
1014
|
+
from harness_process import observe_process_identity
|
|
1015
|
+
|
|
1016
|
+
observed = observe_process_identity(pid)
|
|
1017
|
+
if not observed.get("executable"):
|
|
1018
|
+
observed["executable"] = _absolute_executable(executable_hint)
|
|
1019
|
+
observed["fieldProvenance"]["executable"] = "ATTESTED"
|
|
1020
|
+
if not observed.get("createdAt"):
|
|
1021
|
+
observed["createdAt"] = now_iso()
|
|
1022
|
+
observed["fieldProvenance"]["createdAt"] = "ATTESTED"
|
|
1023
|
+
# ``startedAt`` is a compatibility alias used by legacy session readers;
|
|
1024
|
+
# canonical identity comparisons use ``createdAt`` and provenance.
|
|
1025
|
+
observed["startedAt"] = observed.get("createdAt")
|
|
1026
|
+
return observed
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _log_contract(path: Path) -> dict[str, Any]:
|
|
1030
|
+
raw = b""
|
|
1031
|
+
if path.is_file():
|
|
1032
|
+
try:
|
|
1033
|
+
raw = path.read_bytes()
|
|
1034
|
+
except OSError:
|
|
1035
|
+
raw = b""
|
|
1036
|
+
try:
|
|
1037
|
+
raw.decode("utf-8")
|
|
1038
|
+
decode_status = "OK"
|
|
1039
|
+
except UnicodeDecodeError:
|
|
1040
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
1041
|
+
return {
|
|
1042
|
+
"cursor": len(raw),
|
|
1043
|
+
"rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
1044
|
+
"decodeStatus": decode_status,
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _update_receipt_contract(receipt: dict[str, Any]) -> None:
|
|
1049
|
+
worker = receipt.get("workerIdentity")
|
|
1050
|
+
writer_source = worker if isinstance(worker, dict) else {
|
|
1051
|
+
"pid": receipt.get("workerPid") or receipt.get("launcherPid")
|
|
1052
|
+
}
|
|
1053
|
+
heartbeat_kind = "WORKER" if isinstance(worker, dict) else "SUPERVISOR"
|
|
1054
|
+
receipt["heartbeat"] = {
|
|
1055
|
+
"kind": heartbeat_kind,
|
|
1056
|
+
"writerIdentity": _sha256_json(writer_source),
|
|
1057
|
+
"lastSeenAt": str(receipt.get("lastHeartbeatAt") or now_iso()),
|
|
1058
|
+
"ttlSeconds": max(1, int(float(receipt.get("heartbeatSeconds") or 30))),
|
|
1059
|
+
"staleReason": (
|
|
1060
|
+
"SERVICE_HEARTBEAT_STALE"
|
|
1061
|
+
if receipt.get("reasonCode") == "HEARTBEAT_LOST"
|
|
1062
|
+
else None
|
|
1063
|
+
),
|
|
1064
|
+
}
|
|
1065
|
+
receipt["logs"] = {
|
|
1066
|
+
"stdout": _log_contract(Path(str(receipt.get("stdoutPath") or ""))),
|
|
1067
|
+
"stderr": _log_contract(Path(str(receipt.get("stderrPath") or ""))),
|
|
1068
|
+
}
|
|
1069
|
+
cleanup_status = str(receipt.get("cleanupStatus") or "")
|
|
1070
|
+
complete = cleanup_status in {
|
|
1071
|
+
"NO_CHILD",
|
|
1072
|
+
"PROCESS_EXITED",
|
|
1073
|
+
"PROCESS_TREE_TERMINATED",
|
|
1074
|
+
}
|
|
1075
|
+
receipt["cleanup"] = {
|
|
1076
|
+
"complete": complete,
|
|
1077
|
+
"reasonCode": None if complete else receipt.get("reasonCode"),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _spawn_detached_worker(
|
|
1082
|
+
argv: list[str],
|
|
1083
|
+
*,
|
|
1084
|
+
cwd: Path,
|
|
1085
|
+
env: dict[str, str],
|
|
1086
|
+
output: Any = subprocess.DEVNULL,
|
|
1087
|
+
) -> subprocess.Popen[bytes]:
|
|
1088
|
+
kwargs: dict[str, Any] = {
|
|
1089
|
+
"cwd": str(cwd),
|
|
1090
|
+
"env": env,
|
|
1091
|
+
"stdin": subprocess.DEVNULL,
|
|
1092
|
+
"stdout": output,
|
|
1093
|
+
"stderr": output,
|
|
1094
|
+
"close_fds": True,
|
|
1095
|
+
}
|
|
1096
|
+
if os.name == "nt":
|
|
1097
|
+
detached = 0x00000008
|
|
1098
|
+
new_group = 0x00000200
|
|
1099
|
+
no_window = 0x08000000
|
|
1100
|
+
breakaway = 0x01000000
|
|
1101
|
+
try:
|
|
1102
|
+
return subprocess.Popen(
|
|
1103
|
+
argv,
|
|
1104
|
+
creationflags=detached | new_group | no_window | breakaway,
|
|
1105
|
+
**kwargs,
|
|
1106
|
+
)
|
|
1107
|
+
except OSError:
|
|
1108
|
+
return subprocess.Popen(
|
|
1109
|
+
argv,
|
|
1110
|
+
creationflags=detached | new_group | no_window,
|
|
1111
|
+
**kwargs,
|
|
1112
|
+
)
|
|
1113
|
+
return subprocess.Popen(argv, start_new_session=True, **kwargs)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def start_run_session(
|
|
1117
|
+
*,
|
|
1118
|
+
state_root: Path,
|
|
1119
|
+
verification: str,
|
|
1120
|
+
argv: list[str],
|
|
1121
|
+
working_directory: Path,
|
|
1122
|
+
environment: dict[str, str] | None = None,
|
|
1123
|
+
timeout_seconds: float | None = None,
|
|
1124
|
+
heartbeat_seconds: float = 30.0,
|
|
1125
|
+
expected_duration_seconds: float | None = None,
|
|
1126
|
+
product_identity: str | None = None,
|
|
1127
|
+
resource_locks: list[str] | None = None,
|
|
1128
|
+
run_id: str | None = None,
|
|
1129
|
+
session_id: str | None = None,
|
|
1130
|
+
) -> dict[str, Any]:
|
|
1131
|
+
"""Start a re-attachable managed command without shell re-parsing.
|
|
1132
|
+
|
|
1133
|
+
The short-lived launch specification is removed by the worker immediately
|
|
1134
|
+
after spawn. Environment values are inherited by the worker and hashed in
|
|
1135
|
+
the durable receipt; they are never serialized.
|
|
1136
|
+
"""
|
|
1137
|
+
if not verification.strip():
|
|
1138
|
+
raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
|
|
1139
|
+
if not argv or not all(isinstance(item, str) and item for item in argv):
|
|
1140
|
+
raise ValueError("RUN_SESSION_ARGV_REQUIRED")
|
|
1141
|
+
if heartbeat_seconds <= 0:
|
|
1142
|
+
raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
|
|
1143
|
+
if timeout_seconds is not None and timeout_seconds <= 0:
|
|
1144
|
+
raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
|
|
1145
|
+
normalized_resource_locks: list[str] = []
|
|
1146
|
+
for raw_lock in resource_locks or []:
|
|
1147
|
+
if (
|
|
1148
|
+
not isinstance(raw_lock, str)
|
|
1149
|
+
or not raw_lock.strip()
|
|
1150
|
+
or len(raw_lock.strip()) > 256
|
|
1151
|
+
):
|
|
1152
|
+
raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
|
|
1153
|
+
normalized_resource_locks.append(raw_lock.strip())
|
|
1154
|
+
normalized_resource_locks = sorted(set(normalized_resource_locks))
|
|
1155
|
+
|
|
1156
|
+
state_root = state_root.expanduser().resolve()
|
|
1157
|
+
working_directory = working_directory.expanduser().resolve()
|
|
1158
|
+
if not working_directory.is_dir():
|
|
1159
|
+
raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
|
|
1160
|
+
session_id = session_id or f"run-{uuid.uuid4().hex}"
|
|
1161
|
+
run_id = run_id or session_id
|
|
1162
|
+
session_root = _run_session_root(state_root, session_id)
|
|
1163
|
+
session_root.mkdir(parents=True, exist_ok=False)
|
|
1164
|
+
created_at = now_iso()
|
|
1165
|
+
stdout_path = session_root / "stdout.log"
|
|
1166
|
+
stderr_path = session_root / "stderr.log"
|
|
1167
|
+
worker_log_path = session_root / "worker.log"
|
|
1168
|
+
spec_path = session_root / "launch-spec.json"
|
|
1169
|
+
overrides = dict(environment or {})
|
|
1170
|
+
command_hash = _sha256_json(argv)
|
|
1171
|
+
environment_hash = _sha256_json(overrides)
|
|
1172
|
+
receipt: dict[str, Any] = {
|
|
1173
|
+
"schemaVersion": RUN_SESSION_SCHEMA_VERSION,
|
|
1174
|
+
"sessionId": session_id,
|
|
1175
|
+
"runId": run_id,
|
|
1176
|
+
"verification": verification,
|
|
1177
|
+
"commandHash": command_hash,
|
|
1178
|
+
"workingDirectory": str(working_directory),
|
|
1179
|
+
"environmentHash": environment_hash,
|
|
1180
|
+
"launcherPid": os.getpid(),
|
|
1181
|
+
"workerPid": None,
|
|
1182
|
+
"servicePid": None,
|
|
1183
|
+
"processIdentity": None,
|
|
1184
|
+
"createdAt": created_at,
|
|
1185
|
+
"lastHeartbeatAt": created_at,
|
|
1186
|
+
"startedAt": None,
|
|
1187
|
+
"endedAt": None,
|
|
1188
|
+
"exitCode": None,
|
|
1189
|
+
"status": "STARTING",
|
|
1190
|
+
"reasonCode": "WORKER_STARTING",
|
|
1191
|
+
"stage": "prepare",
|
|
1192
|
+
"stageStartedAt": created_at,
|
|
1193
|
+
"stageTimings": [],
|
|
1194
|
+
"stdoutPath": str(stdout_path),
|
|
1195
|
+
"stderrPath": str(stderr_path),
|
|
1196
|
+
"workerLogPath": str(worker_log_path),
|
|
1197
|
+
"resultDigest": None,
|
|
1198
|
+
"cleanupStatus": "PENDING",
|
|
1199
|
+
"testProcessStarted": False,
|
|
1200
|
+
"timeoutSeconds": timeout_seconds,
|
|
1201
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
1202
|
+
"expectedDurationSeconds": expected_duration_seconds,
|
|
1203
|
+
"productIdentity": product_identity,
|
|
1204
|
+
"resourceLocks": normalized_resource_locks,
|
|
1205
|
+
"resourceLockTokens": {},
|
|
1206
|
+
"resourceWaitMs": 0,
|
|
1207
|
+
"activeTimeMs": 0,
|
|
1208
|
+
"wallClockMs": 0,
|
|
1209
|
+
"diagnosticPath": None,
|
|
1210
|
+
}
|
|
1211
|
+
_write_run_receipt(state_root, receipt)
|
|
1212
|
+
lock_tokens, lock_conflict = _acquire_resource_locks(
|
|
1213
|
+
state_root,
|
|
1214
|
+
session_id,
|
|
1215
|
+
normalized_resource_locks,
|
|
1216
|
+
)
|
|
1217
|
+
if lock_conflict is not None:
|
|
1218
|
+
receipt.update(
|
|
1219
|
+
{
|
|
1220
|
+
"status": "INCOMPLETE",
|
|
1221
|
+
"reasonCode": "RESOURCE_LOCK_BUSY",
|
|
1222
|
+
"endedAt": now_iso(),
|
|
1223
|
+
"cleanupStatus": "NO_CHILD",
|
|
1224
|
+
"resourceLockConflict": lock_conflict,
|
|
1225
|
+
}
|
|
1226
|
+
)
|
|
1227
|
+
_write_run_receipt(state_root, receipt)
|
|
1228
|
+
return receipt
|
|
1229
|
+
receipt["resourceLockTokens"] = lock_tokens
|
|
1230
|
+
atomic_write_json(
|
|
1231
|
+
_resource_lock_secrets_path(state_root, session_id),
|
|
1232
|
+
{"schemaVersion": 1, "tokens": lock_tokens},
|
|
1233
|
+
)
|
|
1234
|
+
try:
|
|
1235
|
+
_resource_lock_secrets_path(state_root, session_id).chmod(0o600)
|
|
1236
|
+
except OSError:
|
|
1237
|
+
pass
|
|
1238
|
+
_write_run_receipt(state_root, receipt)
|
|
1239
|
+
atomic_write_json(
|
|
1240
|
+
spec_path,
|
|
1241
|
+
{
|
|
1242
|
+
"schemaVersion": 1,
|
|
1243
|
+
"stateRoot": str(state_root),
|
|
1244
|
+
"sessionId": session_id,
|
|
1245
|
+
"argv": argv,
|
|
1246
|
+
"heartbeatSeconds": heartbeat_seconds,
|
|
1247
|
+
},
|
|
1248
|
+
)
|
|
1249
|
+
try:
|
|
1250
|
+
spec_path.chmod(0o600)
|
|
1251
|
+
except OSError:
|
|
1252
|
+
pass
|
|
1253
|
+
|
|
1254
|
+
worker_env = {**os.environ, **overrides}
|
|
1255
|
+
worker_env["PYTHONUTF8"] = "1"
|
|
1256
|
+
worker_env["PYTHONIOENCODING"] = "utf-8"
|
|
1257
|
+
worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
|
|
1258
|
+
worker_argv = [
|
|
1259
|
+
_absolute_executable(sys.executable),
|
|
1260
|
+
str(Path(__file__).resolve()),
|
|
1261
|
+
"_worker",
|
|
1262
|
+
"--state-root",
|
|
1263
|
+
str(state_root),
|
|
1264
|
+
"--session-id",
|
|
1265
|
+
session_id,
|
|
1266
|
+
]
|
|
1267
|
+
try:
|
|
1268
|
+
worker_log_path.touch()
|
|
1269
|
+
worker = _spawn_detached_worker(
|
|
1270
|
+
worker_argv,
|
|
1271
|
+
cwd=working_directory,
|
|
1272
|
+
env=worker_env,
|
|
1273
|
+
)
|
|
1274
|
+
except OSError as exc:
|
|
1275
|
+
spec_path.unlink(missing_ok=True)
|
|
1276
|
+
_resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
|
|
1277
|
+
locks_released = _release_resource_locks(
|
|
1278
|
+
state_root,
|
|
1279
|
+
session_id,
|
|
1280
|
+
lock_tokens,
|
|
1281
|
+
)
|
|
1282
|
+
receipt.update(
|
|
1283
|
+
{
|
|
1284
|
+
"status": "INCOMPLETE",
|
|
1285
|
+
"reasonCode": "WORKER_LAUNCH_FAILED",
|
|
1286
|
+
"endedAt": now_iso(),
|
|
1287
|
+
"cleanupStatus": (
|
|
1288
|
+
"NO_CHILD"
|
|
1289
|
+
if locks_released
|
|
1290
|
+
else "RESOURCE_LOCK_RELEASE_FAILED"
|
|
1291
|
+
),
|
|
1292
|
+
"error": str(exc),
|
|
1293
|
+
}
|
|
1294
|
+
)
|
|
1295
|
+
_write_run_receipt(state_root, receipt)
|
|
1296
|
+
return receipt
|
|
1297
|
+
receipt["workerPid"] = worker.pid
|
|
1298
|
+
receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
|
|
1299
|
+
# Best-effort early handoff. The worker repeats and verifies this transfer
|
|
1300
|
+
# before spawning the managed command. Do not rewrite the session receipt
|
|
1301
|
+
# here: the detached worker may already have advanced it to RUNNING.
|
|
1302
|
+
_refresh_resource_locks(
|
|
1303
|
+
state_root,
|
|
1304
|
+
session_id,
|
|
1305
|
+
lock_tokens,
|
|
1306
|
+
dict(receipt["workerIdentity"]),
|
|
1307
|
+
)
|
|
1308
|
+
threading.Thread(target=worker.wait, daemon=True).start()
|
|
1309
|
+
return receipt
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _terminate_managed_child(
|
|
1313
|
+
process: subprocess.Popen[bytes],
|
|
1314
|
+
job: Any,
|
|
1315
|
+
*,
|
|
1316
|
+
attestation: dict[str, Any] | None = None,
|
|
1317
|
+
ownership_proof: dict[str, Any] | None = None,
|
|
1318
|
+
) -> bool:
|
|
1319
|
+
from harness_process import terminate_owned_tree
|
|
1320
|
+
|
|
1321
|
+
if isinstance(attestation, dict) and isinstance(ownership_proof, dict):
|
|
1322
|
+
result = terminate_owned_tree(
|
|
1323
|
+
attestation,
|
|
1324
|
+
ownership_proof,
|
|
1325
|
+
{"graceSeconds": 3.0},
|
|
1326
|
+
)
|
|
1327
|
+
if not result.get("cleanupComplete"):
|
|
1328
|
+
return False
|
|
1329
|
+
elif job is not None and getattr(job, "handle", None) is not None:
|
|
1330
|
+
# Compatibility for callers still passing the old Windows job object;
|
|
1331
|
+
# this branch is only used for an already-owned handle, never a PID.
|
|
1332
|
+
job.terminate_and_wait()
|
|
1333
|
+
else:
|
|
1334
|
+
# PID-only cleanup is intentionally forbidden by the process provider.
|
|
1335
|
+
return False
|
|
1336
|
+
try:
|
|
1337
|
+
process.wait(timeout=5)
|
|
1338
|
+
except subprocess.TimeoutExpired:
|
|
1339
|
+
return False
|
|
1340
|
+
return process.poll() is not None
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _run_session_worker(state_root: Path, session_id: str) -> int:
|
|
1344
|
+
from harness_service import verify_process_identity
|
|
1345
|
+
|
|
1346
|
+
session_root = _run_session_root(state_root, session_id)
|
|
1347
|
+
spec_path = session_root / "launch-spec.json"
|
|
1348
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1349
|
+
receipt["resourceLockTokens"] = _load_resource_lock_tokens(
|
|
1350
|
+
state_root,
|
|
1351
|
+
session_id,
|
|
1352
|
+
receipt,
|
|
1353
|
+
)
|
|
1354
|
+
try:
|
|
1355
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
|
1356
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1357
|
+
_release_resource_locks(
|
|
1358
|
+
state_root,
|
|
1359
|
+
session_id,
|
|
1360
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1361
|
+
)
|
|
1362
|
+
receipt.update(
|
|
1363
|
+
{
|
|
1364
|
+
"status": "INCOMPLETE",
|
|
1365
|
+
"reasonCode": "LAUNCH_SPEC_INVALID",
|
|
1366
|
+
"endedAt": now_iso(),
|
|
1367
|
+
"cleanupStatus": "NO_CHILD",
|
|
1368
|
+
"error": str(exc),
|
|
1369
|
+
}
|
|
1370
|
+
)
|
|
1371
|
+
_write_run_receipt(state_root, receipt)
|
|
1372
|
+
return 1
|
|
1373
|
+
argv = [str(item) for item in spec.get("argv", [])]
|
|
1374
|
+
heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
|
|
1375
|
+
spec_path.unlink(missing_ok=True)
|
|
1376
|
+
started_monotonic = time.monotonic()
|
|
1377
|
+
_stage_transition(receipt, "spawn")
|
|
1378
|
+
receipt.update(
|
|
1379
|
+
{
|
|
1380
|
+
"workerPid": os.getpid(),
|
|
1381
|
+
"workerIdentity": _process_identity(os.getpid(), sys.executable),
|
|
1382
|
+
"lastHeartbeatAt": now_iso(),
|
|
1383
|
+
}
|
|
1384
|
+
)
|
|
1385
|
+
if not _refresh_resource_locks(
|
|
1386
|
+
state_root,
|
|
1387
|
+
session_id,
|
|
1388
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1389
|
+
dict(receipt["workerIdentity"]),
|
|
1390
|
+
):
|
|
1391
|
+
receipt.update(
|
|
1392
|
+
{
|
|
1393
|
+
"status": "INCOMPLETE",
|
|
1394
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1395
|
+
"endedAt": now_iso(),
|
|
1396
|
+
"cleanupStatus": "NO_CHILD",
|
|
1397
|
+
}
|
|
1398
|
+
)
|
|
1399
|
+
_write_run_receipt(state_root, receipt)
|
|
1400
|
+
return 1
|
|
1401
|
+
_write_run_receipt(state_root, receipt)
|
|
1402
|
+
|
|
1403
|
+
stdout_path = Path(str(receipt["stdoutPath"]))
|
|
1404
|
+
stderr_path = Path(str(receipt["stderrPath"]))
|
|
1405
|
+
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1406
|
+
job = None
|
|
1407
|
+
spawned = None
|
|
1408
|
+
ownership_proof: dict[str, Any] | None = None
|
|
1409
|
+
identity: dict[str, Any] | None = None
|
|
1410
|
+
process: subprocess.Popen[bytes] | None = None
|
|
1411
|
+
try:
|
|
1412
|
+
with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
|
|
1413
|
+
"wb", buffering=0
|
|
1414
|
+
) as stderr:
|
|
1415
|
+
try:
|
|
1416
|
+
from harness_process import spawn_structured_argv
|
|
1417
|
+
|
|
1418
|
+
spawned = spawn_structured_argv(
|
|
1419
|
+
argv,
|
|
1420
|
+
cwd=Path(str(receipt["workingDirectory"])),
|
|
1421
|
+
environment={
|
|
1422
|
+
"PYTHONUTF8": "1",
|
|
1423
|
+
"PYTHONIOENCODING": "utf-8",
|
|
1424
|
+
},
|
|
1425
|
+
owner_token=f"run:{session_id}",
|
|
1426
|
+
stdout=stdout,
|
|
1427
|
+
stderr=stderr,
|
|
1428
|
+
)
|
|
1429
|
+
process = spawned.process
|
|
1430
|
+
ownership_proof = spawned.ownershipProof
|
|
1431
|
+
_capture_provider_members(spawned)
|
|
1432
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1433
|
+
receipt.update(
|
|
1434
|
+
{
|
|
1435
|
+
"status": "INCOMPLETE",
|
|
1436
|
+
"reasonCode": "LAUNCHER_FAILED",
|
|
1437
|
+
"endedAt": now_iso(),
|
|
1438
|
+
"exitCode": None,
|
|
1439
|
+
"testProcessStarted": False,
|
|
1440
|
+
"cleanupStatus": "NO_CHILD",
|
|
1441
|
+
"error": str(exc),
|
|
1442
|
+
}
|
|
1443
|
+
)
|
|
1444
|
+
return 1
|
|
1445
|
+
assert spawned is not None and process is not None
|
|
1446
|
+
identity = spawned.attestation
|
|
1447
|
+
receipt["processOwnershipProof"] = ownership_proof
|
|
1448
|
+
_stage_transition(receipt, "execute")
|
|
1449
|
+
receipt.update(
|
|
1450
|
+
{
|
|
1451
|
+
"servicePid": process.pid,
|
|
1452
|
+
"processIdentity": identity,
|
|
1453
|
+
"startedAt": identity.get("createdAt"),
|
|
1454
|
+
"status": "RUNNING",
|
|
1455
|
+
"reasonCode": "CHILD_RUNNING",
|
|
1456
|
+
"testProcessStarted": True,
|
|
1457
|
+
"processTreeIsolated": ownership_proof is not None,
|
|
1458
|
+
}
|
|
1459
|
+
)
|
|
1460
|
+
_write_run_receipt(state_root, receipt)
|
|
1461
|
+
|
|
1462
|
+
timeout_seconds = receipt.get("timeoutSeconds")
|
|
1463
|
+
next_heartbeat = time.monotonic()
|
|
1464
|
+
termination_reason: str | None = None
|
|
1465
|
+
while process.poll() is None:
|
|
1466
|
+
now = time.monotonic()
|
|
1467
|
+
if timeout_seconds is not None and (
|
|
1468
|
+
now - started_monotonic >= float(timeout_seconds)
|
|
1469
|
+
):
|
|
1470
|
+
termination_reason = "TIMEOUT"
|
|
1471
|
+
cancel_path = session_root / "cancel-request.json"
|
|
1472
|
+
if cancel_path.is_file():
|
|
1473
|
+
termination_reason = "CANCEL_REQUESTED"
|
|
1474
|
+
if termination_reason is not None:
|
|
1475
|
+
diagnostic_path = session_root / "diagnostic.json"
|
|
1476
|
+
from harness_process import (
|
|
1477
|
+
observe_process_identity,
|
|
1478
|
+
verify_process_identity as verify_attestation,
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
verified = verify_attestation(
|
|
1482
|
+
identity,
|
|
1483
|
+
observe_process_identity(process.pid),
|
|
1484
|
+
).get("ok") is True
|
|
1485
|
+
job_owned = ownership_proof is not None
|
|
1486
|
+
if spawned is not None:
|
|
1487
|
+
_capture_provider_members(spawned)
|
|
1488
|
+
diagnostic = {
|
|
1489
|
+
"schemaVersion": 1,
|
|
1490
|
+
"sessionId": session_id,
|
|
1491
|
+
"reasonCode": termination_reason,
|
|
1492
|
+
"observedAt": now_iso(),
|
|
1493
|
+
"processIdentityVerified": verified,
|
|
1494
|
+
"terminationAuthority": (
|
|
1495
|
+
"JOB_OBJECT_OWNED"
|
|
1496
|
+
if job_owned
|
|
1497
|
+
else (
|
|
1498
|
+
"PROCESS_IDENTITY_VERIFIED"
|
|
1499
|
+
if verified
|
|
1500
|
+
else "NONE"
|
|
1501
|
+
)
|
|
1502
|
+
),
|
|
1503
|
+
"pid": process.pid,
|
|
1504
|
+
"stdoutBytes": (
|
|
1505
|
+
stdout_path.stat().st_size if stdout_path.exists() else 0
|
|
1506
|
+
),
|
|
1507
|
+
"stderrBytes": (
|
|
1508
|
+
stderr_path.stat().st_size if stderr_path.exists() else 0
|
|
1509
|
+
),
|
|
1510
|
+
}
|
|
1511
|
+
atomic_write_json(diagnostic_path, diagnostic)
|
|
1512
|
+
receipt["diagnosticPath"] = str(diagnostic_path)
|
|
1513
|
+
if job_owned and verified:
|
|
1514
|
+
terminated = _terminate_managed_child(
|
|
1515
|
+
process,
|
|
1516
|
+
job,
|
|
1517
|
+
attestation=identity,
|
|
1518
|
+
ownership_proof=ownership_proof,
|
|
1519
|
+
)
|
|
1520
|
+
job = None
|
|
1521
|
+
if not terminated:
|
|
1522
|
+
receipt.update(
|
|
1523
|
+
{
|
|
1524
|
+
"status": "INCOMPLETE",
|
|
1525
|
+
"reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
1526
|
+
"cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
|
|
1527
|
+
}
|
|
1528
|
+
)
|
|
1529
|
+
else:
|
|
1530
|
+
receipt.update(
|
|
1531
|
+
{
|
|
1532
|
+
"status": "INCOMPLETE",
|
|
1533
|
+
"reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
|
|
1534
|
+
"cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
|
|
1535
|
+
}
|
|
1536
|
+
)
|
|
1537
|
+
break
|
|
1538
|
+
if now >= next_heartbeat:
|
|
1539
|
+
receipt["lastHeartbeatAt"] = now_iso()
|
|
1540
|
+
receipt["activeTimeMs"] = int(
|
|
1541
|
+
(now - started_monotonic) * 1000
|
|
1542
|
+
)
|
|
1543
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"]
|
|
1544
|
+
if not _refresh_resource_locks(
|
|
1545
|
+
state_root,
|
|
1546
|
+
session_id,
|
|
1547
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1548
|
+
dict(receipt["workerIdentity"]),
|
|
1549
|
+
):
|
|
1550
|
+
receipt.update(
|
|
1551
|
+
{
|
|
1552
|
+
"status": "INCOMPLETE",
|
|
1553
|
+
"reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1554
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
|
|
1555
|
+
}
|
|
1556
|
+
)
|
|
1557
|
+
if spawned is not None:
|
|
1558
|
+
_capture_provider_members(spawned)
|
|
1559
|
+
_terminate_managed_child(
|
|
1560
|
+
process,
|
|
1561
|
+
job,
|
|
1562
|
+
attestation=identity,
|
|
1563
|
+
ownership_proof=ownership_proof,
|
|
1564
|
+
)
|
|
1565
|
+
job = None
|
|
1566
|
+
break
|
|
1567
|
+
_write_run_receipt(state_root, receipt)
|
|
1568
|
+
next_heartbeat = now + heartbeat_seconds
|
|
1569
|
+
time.sleep(min(heartbeat_seconds, 0.05))
|
|
1570
|
+
|
|
1571
|
+
exit_code = process.poll()
|
|
1572
|
+
_stage_transition(receipt, "summarize")
|
|
1573
|
+
if termination_reason is not None and receipt["reasonCode"] not in {
|
|
1574
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
1575
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
1576
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1577
|
+
}:
|
|
1578
|
+
receipt.update(
|
|
1579
|
+
{
|
|
1580
|
+
"status": "CANCELLED",
|
|
1581
|
+
"reasonCode": termination_reason,
|
|
1582
|
+
"exitCode": exit_code,
|
|
1583
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATED",
|
|
1584
|
+
}
|
|
1585
|
+
)
|
|
1586
|
+
elif receipt["reasonCode"] not in {
|
|
1587
|
+
"PROCESS_IDENTITY_UNVERIFIED",
|
|
1588
|
+
"PROCESS_TERMINATION_UNCONFIRMED",
|
|
1589
|
+
"RESOURCE_LOCK_OWNERSHIP_LOST",
|
|
1590
|
+
}:
|
|
1591
|
+
receipt.update(
|
|
1592
|
+
{
|
|
1593
|
+
"status": "OK" if exit_code == 0 else "FAIL",
|
|
1594
|
+
"reasonCode": (
|
|
1595
|
+
"CHILD_EXIT_ZERO"
|
|
1596
|
+
if exit_code == 0
|
|
1597
|
+
else "CHILD_EXIT_NONZERO"
|
|
1598
|
+
),
|
|
1599
|
+
"exitCode": exit_code,
|
|
1600
|
+
"cleanupStatus": "PROCESS_EXITED",
|
|
1601
|
+
}
|
|
1602
|
+
)
|
|
1603
|
+
except BaseException as exc:
|
|
1604
|
+
receipt.update(
|
|
1605
|
+
{
|
|
1606
|
+
"status": "INCOMPLETE",
|
|
1607
|
+
"reasonCode": "WORKER_FAILED",
|
|
1608
|
+
"exitCode": None if process is None else process.poll(),
|
|
1609
|
+
"cleanupStatus": "WORKER_EXCEPTION",
|
|
1610
|
+
"error": str(exc),
|
|
1611
|
+
}
|
|
1612
|
+
)
|
|
1613
|
+
finally:
|
|
1614
|
+
if process is not None and process.poll() is None and ownership_proof is not None:
|
|
1615
|
+
if spawned is not None:
|
|
1616
|
+
_capture_provider_members(spawned)
|
|
1617
|
+
tree_drained = _terminate_managed_child(
|
|
1618
|
+
process,
|
|
1619
|
+
job,
|
|
1620
|
+
attestation=identity if isinstance(identity, dict) else None,
|
|
1621
|
+
ownership_proof=ownership_proof,
|
|
1622
|
+
)
|
|
1623
|
+
if not tree_drained:
|
|
1624
|
+
receipt.update(
|
|
1625
|
+
{
|
|
1626
|
+
"status": "INCOMPLETE",
|
|
1627
|
+
"reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
1628
|
+
"cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
|
|
1629
|
+
}
|
|
1630
|
+
)
|
|
1631
|
+
elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
|
|
1632
|
+
receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
|
|
1633
|
+
ended = time.monotonic()
|
|
1634
|
+
_stage_transition(receipt, "record")
|
|
1635
|
+
receipt["endedAt"] = now_iso()
|
|
1636
|
+
receipt["lastHeartbeatAt"] = receipt["endedAt"]
|
|
1637
|
+
receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
|
|
1638
|
+
receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
|
|
1639
|
+
receipt.get("resourceWaitMs") or 0
|
|
1640
|
+
)
|
|
1641
|
+
digest = hashlib.sha256()
|
|
1642
|
+
for path in (stdout_path, stderr_path):
|
|
1643
|
+
if path.is_file():
|
|
1644
|
+
digest.update(path.read_bytes())
|
|
1645
|
+
receipt["resultDigest"] = "sha256:" + digest.hexdigest()
|
|
1646
|
+
locks_released = _release_resource_locks(
|
|
1647
|
+
state_root,
|
|
1648
|
+
session_id,
|
|
1649
|
+
dict(receipt.get("resourceLockTokens") or {}),
|
|
1650
|
+
)
|
|
1651
|
+
try:
|
|
1652
|
+
_resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
|
|
1653
|
+
except OSError:
|
|
1654
|
+
locks_released = False
|
|
1655
|
+
if not locks_released:
|
|
1656
|
+
receipt.update(
|
|
1657
|
+
{
|
|
1658
|
+
"status": "INCOMPLETE",
|
|
1659
|
+
"reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
1660
|
+
"cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
|
|
1661
|
+
}
|
|
1662
|
+
)
|
|
1663
|
+
_close_current_stage(receipt)
|
|
1664
|
+
_write_run_receipt(state_root, receipt)
|
|
1665
|
+
return 0 if receipt.get("status") == "OK" else 1
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
|
|
1669
|
+
worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
|
|
1670
|
+
try:
|
|
1671
|
+
worker_log = worker_log_path.open(
|
|
1672
|
+
"a",
|
|
1673
|
+
encoding="utf-8",
|
|
1674
|
+
errors="replace",
|
|
1675
|
+
buffering=1,
|
|
1676
|
+
)
|
|
1677
|
+
except OSError:
|
|
1678
|
+
return _run_session_worker(state_root, session_id)
|
|
1679
|
+
with worker_log:
|
|
1680
|
+
with (
|
|
1681
|
+
contextlib.redirect_stdout(worker_log),
|
|
1682
|
+
contextlib.redirect_stderr(worker_log),
|
|
1683
|
+
):
|
|
1684
|
+
return _run_session_worker(state_root, session_id)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _run_session_status_raw(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
1688
|
+
from harness_service import is_pid_alive
|
|
1689
|
+
|
|
1690
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1691
|
+
worker_pid = receipt.get("workerPid")
|
|
1692
|
+
worker_identity = receipt.get("workerIdentity")
|
|
1693
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1694
|
+
if (
|
|
1695
|
+
isinstance(worker_pid, int)
|
|
1696
|
+
and worker_pid > 0
|
|
1697
|
+
and is_pid_alive(worker_pid)
|
|
1698
|
+
and _receipt_identity_state(receipt) is True
|
|
1699
|
+
):
|
|
1700
|
+
finalizing = dict(receipt)
|
|
1701
|
+
finalizing.update(
|
|
1702
|
+
{
|
|
1703
|
+
"status": "FINALIZING",
|
|
1704
|
+
"reasonCode": "WORKER_FINALIZING",
|
|
1705
|
+
}
|
|
1706
|
+
)
|
|
1707
|
+
return finalizing
|
|
1708
|
+
return receipt
|
|
1709
|
+
heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
|
|
1710
|
+
try:
|
|
1711
|
+
heartbeat_at = dt.datetime.fromisoformat(
|
|
1712
|
+
heartbeat_text.replace("Z", "+00:00")
|
|
1713
|
+
)
|
|
1714
|
+
if heartbeat_at.tzinfo is None:
|
|
1715
|
+
heartbeat_at = heartbeat_at.astimezone()
|
|
1716
|
+
heartbeat_age = (
|
|
1717
|
+
dt.datetime.now().astimezone() - heartbeat_at
|
|
1718
|
+
).total_seconds()
|
|
1719
|
+
except ValueError:
|
|
1720
|
+
heartbeat_age = float("inf")
|
|
1721
|
+
grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
|
|
1722
|
+
startup_grace = max(grace, 10.0)
|
|
1723
|
+
# The detached worker owns the durable identity record. The launcher can
|
|
1724
|
+
# return before the worker has published it, so STARTING is intentionally
|
|
1725
|
+
# treated as a bounded handshake window rather than an identity failure.
|
|
1726
|
+
if (
|
|
1727
|
+
receipt.get("status") == "STARTING"
|
|
1728
|
+
and receipt.get("workerIdentity") is None
|
|
1729
|
+
and heartbeat_age < startup_grace
|
|
1730
|
+
):
|
|
1731
|
+
return receipt
|
|
1732
|
+
identity_state = _receipt_identity_state(receipt)
|
|
1733
|
+
worker_gone = (
|
|
1734
|
+
isinstance(worker_pid, int)
|
|
1735
|
+
and worker_pid > 0
|
|
1736
|
+
and not is_pid_alive(worker_pid)
|
|
1737
|
+
)
|
|
1738
|
+
if worker_gone or identity_state is False or heartbeat_age >= grace:
|
|
1739
|
+
latest = _load_run_receipt(state_root, session_id)
|
|
1740
|
+
if latest.get("status") in RUN_TERMINAL_STATUSES:
|
|
1741
|
+
return latest
|
|
1742
|
+
if heartbeat_age < grace and worker_gone and identity_state is not False:
|
|
1743
|
+
return latest
|
|
1744
|
+
receipt.update(
|
|
1745
|
+
{
|
|
1746
|
+
"status": "INCOMPLETE",
|
|
1747
|
+
"reasonCode": (
|
|
1748
|
+
"WORKER_IDENTITY_MISMATCH"
|
|
1749
|
+
if identity_state is False and not worker_gone
|
|
1750
|
+
else "HEARTBEAT_LOST"
|
|
1751
|
+
),
|
|
1752
|
+
"endedAt": now_iso(),
|
|
1753
|
+
"cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
|
|
1754
|
+
}
|
|
1755
|
+
)
|
|
1756
|
+
_write_run_receipt(state_root, receipt)
|
|
1757
|
+
return receipt
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
# 终态判定此前只活在脚本内部的 RUN_TERMINAL_STATUSES 里,从不出现在返回体中。
|
|
1761
|
+
# 调用方于是只能按状态名猜——而 "INCOMPLETE" 读起来最像"还没结束",实际是终态。
|
|
1762
|
+
# 2026-08-18 的一次 fixback 执行里,一个启动即失败(LAUNCHER_FAILED)的会话
|
|
1763
|
+
# 被连等 20s、60s,纯属这个缺口造成的浪费。
|
|
1764
|
+
_TERMINAL_HINTS = {
|
|
1765
|
+
"LAUNCHER_FAILED": (
|
|
1766
|
+
"被测进程未能启动(testProcessStarted=false),不是超时;"
|
|
1767
|
+
"先核对 argv 的可执行文件在该工作目录下能否直接运行,再重跑 run-start。"
|
|
1768
|
+
),
|
|
1769
|
+
"HEARTBEAT_LOST": "worker 心跳丢失,会话已判定结束;日志可能不完整。",
|
|
1770
|
+
"WORKER_IDENTITY_MISMATCH": "worker 身份校验不通过,会话已终止且未采信其结果。",
|
|
1771
|
+
"WORKER_EXITED_WITHOUT_FINAL_RECEIPT": "worker 未写最终回执即退出。",
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def _annotate_run_status(receipt: dict[str, Any]) -> dict[str, Any]:
|
|
1776
|
+
"""给回执补上可判定的终态标记与可行动线索。
|
|
1777
|
+
|
|
1778
|
+
FINALIZING 刻意判为非终态:结果虽已确定,但 worker 尚未退出,
|
|
1779
|
+
调用方此时取读数会与清理竞争。
|
|
1780
|
+
"""
|
|
1781
|
+
status = receipt.get("status")
|
|
1782
|
+
terminal = status in RUN_TERMINAL_STATUSES and status != "FINALIZING"
|
|
1783
|
+
annotated = dict(receipt)
|
|
1784
|
+
annotated["terminal"] = bool(terminal)
|
|
1785
|
+
hint = _TERMINAL_HINTS.get(str(receipt.get("reasonCode") or ""))
|
|
1786
|
+
if terminal and hint is not None:
|
|
1787
|
+
annotated["terminalHint"] = hint
|
|
1788
|
+
return annotated
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
|
|
1792
|
+
return _annotate_run_status(_run_session_status_raw(state_root, session_id))
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def await_run_session(
|
|
1796
|
+
state_root: Path,
|
|
1797
|
+
session_id: str,
|
|
1798
|
+
*,
|
|
1799
|
+
timeout_seconds: float = 600.0,
|
|
1800
|
+
poll_seconds: float = 2.0,
|
|
1801
|
+
) -> dict[str, Any]:
|
|
1802
|
+
"""阻塞到会话进入终态,或超时后带 waitTimedOut 标记返回。
|
|
1803
|
+
|
|
1804
|
+
没有这个入口时,调用方只能 `sleep <猜一个时长>` 再查一次;上述执行日志里
|
|
1805
|
+
连猜了 5s / 20s / 60s / 100s 四轮。等待逻辑属于会话语义,应由这里承担。
|
|
1806
|
+
"""
|
|
1807
|
+
deadline = time.monotonic() + max(float(timeout_seconds), 0.0)
|
|
1808
|
+
while True:
|
|
1809
|
+
current = run_session_status(state_root, session_id)
|
|
1810
|
+
if current.get("terminal") is True:
|
|
1811
|
+
current["waitTimedOut"] = False
|
|
1812
|
+
return current
|
|
1813
|
+
if time.monotonic() >= deadline:
|
|
1814
|
+
current["waitTimedOut"] = True
|
|
1815
|
+
return current
|
|
1816
|
+
time.sleep(max(float(poll_seconds), 0.01))
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
def read_run_session_log(
|
|
1820
|
+
state_root: Path,
|
|
1821
|
+
session_id: str,
|
|
1822
|
+
*,
|
|
1823
|
+
stream: str = "stdout",
|
|
1824
|
+
cursor: int = 0,
|
|
1825
|
+
max_bytes: int = 64 * 1024,
|
|
1826
|
+
) -> dict[str, Any]:
|
|
1827
|
+
if stream not in {"stdout", "stderr"}:
|
|
1828
|
+
raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
|
|
1829
|
+
if cursor < 0 or max_bytes <= 0:
|
|
1830
|
+
raise ValueError("RUN_SESSION_CURSOR_INVALID")
|
|
1831
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1832
|
+
path = Path(str(receipt[f"{stream}Path"]))
|
|
1833
|
+
raw = b""
|
|
1834
|
+
decode_status = "OK"
|
|
1835
|
+
size = path.stat().st_size if path.is_file() else 0
|
|
1836
|
+
if path.is_file() and cursor < size:
|
|
1837
|
+
with path.open("rb") as handle:
|
|
1838
|
+
handle.seek(cursor)
|
|
1839
|
+
raw = handle.read(max_bytes)
|
|
1840
|
+
while raw:
|
|
1841
|
+
try:
|
|
1842
|
+
text = raw.decode("utf-8")
|
|
1843
|
+
break
|
|
1844
|
+
except UnicodeDecodeError as exc:
|
|
1845
|
+
if exc.end != len(raw):
|
|
1846
|
+
# An invalid sequence in the middle is durable evidence,
|
|
1847
|
+
# so consume it and expose replacement text instead of
|
|
1848
|
+
# silently dropping bytes from the cursor stream.
|
|
1849
|
+
text = raw.decode("utf-8", errors="replace")
|
|
1850
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
1851
|
+
break
|
|
1852
|
+
if (
|
|
1853
|
+
receipt.get("status") in RUN_TERMINAL_STATUSES
|
|
1854
|
+
and cursor + len(raw) >= size
|
|
1855
|
+
):
|
|
1856
|
+
# Once the producer is terminal, an incomplete trailing
|
|
1857
|
+
# sequence cannot be completed by a later write. Preserve
|
|
1858
|
+
# and consume it with an explicit degraded status.
|
|
1859
|
+
text = raw.decode("utf-8", errors="replace")
|
|
1860
|
+
decode_status = "LOG_DECODE_DEGRADED"
|
|
1861
|
+
break
|
|
1862
|
+
# A live writer may have split a multibyte character across
|
|
1863
|
+
# this read. Keep the incomplete suffix for the next cursor
|
|
1864
|
+
# request so callers never see duplicate replacement glyphs.
|
|
1865
|
+
raw = raw[: exc.start]
|
|
1866
|
+
else:
|
|
1867
|
+
text = ""
|
|
1868
|
+
else:
|
|
1869
|
+
text = ""
|
|
1870
|
+
next_cursor = cursor + len(raw)
|
|
1871
|
+
terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
|
|
1872
|
+
return {
|
|
1873
|
+
"ok": True,
|
|
1874
|
+
"sessionId": session_id,
|
|
1875
|
+
"stream": stream,
|
|
1876
|
+
"cursor": cursor,
|
|
1877
|
+
"nextCursor": next_cursor,
|
|
1878
|
+
"text": text,
|
|
1879
|
+
"eof": terminal and next_cursor >= size,
|
|
1880
|
+
"status": receipt.get("status"),
|
|
1881
|
+
"rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
1882
|
+
"decodeStatus": decode_status,
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def cancel_run_session(
|
|
1887
|
+
state_root: Path,
|
|
1888
|
+
session_id: str,
|
|
1889
|
+
*,
|
|
1890
|
+
reason: str = "CANCEL_REQUESTED",
|
|
1891
|
+
) -> dict[str, Any]:
|
|
1892
|
+
receipt = _load_run_receipt(state_root, session_id)
|
|
1893
|
+
if receipt.get("status") in RUN_TERMINAL_STATUSES:
|
|
1894
|
+
return receipt
|
|
1895
|
+
if _receipt_identity_state(receipt) is not True:
|
|
1896
|
+
return {
|
|
1897
|
+
**receipt,
|
|
1898
|
+
"ok": False,
|
|
1899
|
+
"code": "WORKER_IDENTITY_UNVERIFIED",
|
|
1900
|
+
"unknownProcessUntouched": True,
|
|
1901
|
+
}
|
|
1902
|
+
atomic_write_json(
|
|
1903
|
+
_run_session_root(state_root, session_id) / "cancel-request.json",
|
|
1904
|
+
{"reasonCode": reason, "requestedAt": now_iso()},
|
|
1905
|
+
)
|
|
1906
|
+
return {**receipt, "ok": True, "action": "cancel-requested"}
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
|
|
1910
|
+
if agent not in _ADAPTERS:
|
|
1911
|
+
raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
|
|
1912
|
+
if not _CHANGE_ID.fullmatch(change_id) or change_id in {".", ".."}:
|
|
1913
|
+
raise ValueError(f"ADAPTER_CHANGE_ID_INVALID: {change_id}")
|
|
1914
|
+
root, prefix = _ADAPTERS[agent]
|
|
1915
|
+
return {
|
|
1916
|
+
"agent": agent,
|
|
1917
|
+
"worktreeRoot": root,
|
|
1918
|
+
"path": f"{root}/{change_id}",
|
|
1919
|
+
"branchPrefix": prefix,
|
|
1920
|
+
"branch": f"{prefix}{change_id}",
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
def _absolute_executable(value: str | Path) -> str:
|
|
1925
|
+
return str(Path(value).expanduser().resolve())
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _run_version(executable: str, *args: str) -> dict[str, Any]:
|
|
1929
|
+
try:
|
|
1930
|
+
proc = subprocess.run(
|
|
1931
|
+
[executable, *args],
|
|
1932
|
+
capture_output=True,
|
|
1933
|
+
text=True,
|
|
1934
|
+
encoding="utf-8",
|
|
1935
|
+
errors="replace",
|
|
1936
|
+
check=False,
|
|
1937
|
+
timeout=10,
|
|
1938
|
+
)
|
|
1939
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1940
|
+
return {"available": False, "error": str(exc)}
|
|
1941
|
+
version = (proc.stdout or proc.stderr).strip().splitlines()
|
|
1942
|
+
return {
|
|
1943
|
+
"available": proc.returncode == 0,
|
|
1944
|
+
"version": version[0] if version else "",
|
|
1945
|
+
"exitCode": proc.returncode,
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def probe_powershell(
|
|
1950
|
+
executable: Path,
|
|
1951
|
+
*,
|
|
1952
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
1953
|
+
) -> dict[str, Any]:
|
|
1954
|
+
"""Probe Windows PowerShell/Pwsh without relying on PowerShell 6 Test-Json."""
|
|
1955
|
+
script = (
|
|
1956
|
+
"$value = [ordered]@{"
|
|
1957
|
+
"edition = [string]$PSVersionTable.PSEdition;"
|
|
1958
|
+
"version = [string]$PSVersionTable.PSVersion.ToString()"
|
|
1959
|
+
"}; $value | ConvertTo-Json -Compress"
|
|
1960
|
+
)
|
|
1961
|
+
argv = [
|
|
1962
|
+
_absolute_executable(executable),
|
|
1963
|
+
"-NoLogo",
|
|
1964
|
+
"-NoProfile",
|
|
1965
|
+
"-NonInteractive",
|
|
1966
|
+
"-Command",
|
|
1967
|
+
script,
|
|
1968
|
+
]
|
|
1969
|
+
try:
|
|
1970
|
+
proc = runner(
|
|
1971
|
+
argv,
|
|
1972
|
+
capture_output=True,
|
|
1973
|
+
text=True,
|
|
1974
|
+
encoding="utf-8",
|
|
1975
|
+
errors="replace",
|
|
1976
|
+
check=False,
|
|
1977
|
+
timeout=10,
|
|
1978
|
+
)
|
|
1979
|
+
if proc.returncode != 0:
|
|
1980
|
+
return {
|
|
1981
|
+
"available": False,
|
|
1982
|
+
"executable": argv[0],
|
|
1983
|
+
"argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
|
|
1984
|
+
"error": proc.stderr.strip(),
|
|
1985
|
+
"jsonCapability": "convert-to-json",
|
|
1986
|
+
}
|
|
1987
|
+
raw = json.loads(proc.stdout.strip())
|
|
1988
|
+
return {
|
|
1989
|
+
"available": True,
|
|
1990
|
+
"executable": argv[0],
|
|
1991
|
+
"argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
|
|
1992
|
+
"edition": str(raw.get("edition") or "Desktop"),
|
|
1993
|
+
"version": str(raw.get("version") or ""),
|
|
1994
|
+
"jsonCapability": "convert-to-json",
|
|
1995
|
+
}
|
|
1996
|
+
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
1997
|
+
return {
|
|
1998
|
+
"available": False,
|
|
1999
|
+
"executable": argv[0],
|
|
2000
|
+
"argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
|
|
2001
|
+
"error": str(exc),
|
|
2002
|
+
"jsonCapability": "convert-to-json",
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _optional_runtime(name: str, version_arg: str = "--version") -> dict[str, Any]:
|
|
2007
|
+
found = shutil.which(name)
|
|
2008
|
+
if not found:
|
|
2009
|
+
return {"available": False, "executable": None, "argvPrefix": []}
|
|
2010
|
+
executable = _absolute_executable(found)
|
|
2011
|
+
return {
|
|
2012
|
+
"executable": executable,
|
|
2013
|
+
"argvPrefix": [executable],
|
|
2014
|
+
**_run_version(executable, version_arg),
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def doctor(project: Path, change_dir: Path, *, agent: str) -> dict[str, Any]:
|
|
2019
|
+
project = project.expanduser().resolve()
|
|
2020
|
+
change_dir = change_dir.expanduser().resolve()
|
|
2021
|
+
python_executable = _absolute_executable(sys.executable)
|
|
2022
|
+
python_version = _run_version(python_executable, "--version")
|
|
2023
|
+
|
|
2024
|
+
powershell_path = shutil.which("pwsh") or shutil.which("powershell")
|
|
2025
|
+
powershell = (
|
|
2026
|
+
probe_powershell(Path(powershell_path))
|
|
2027
|
+
if powershell_path
|
|
2028
|
+
else {
|
|
2029
|
+
"available": False,
|
|
2030
|
+
"executable": None,
|
|
2031
|
+
"argvPrefix": [],
|
|
2032
|
+
"edition": None,
|
|
2033
|
+
"version": None,
|
|
2034
|
+
"jsonCapability": "python-json",
|
|
2035
|
+
}
|
|
2036
|
+
)
|
|
2037
|
+
sample = {"path": "E:/示例/计划", "ok": True}
|
|
2038
|
+
json_round_trip = json.loads(json.dumps(sample, ensure_ascii=False)) == sample
|
|
2039
|
+
payload = {
|
|
2040
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
2041
|
+
"generatedAt": now_iso(),
|
|
2042
|
+
"projectRoot": str(project),
|
|
2043
|
+
"changeDir": str(change_dir),
|
|
2044
|
+
"adapter": adapter_worktree(agent, change_dir.name),
|
|
2045
|
+
"runtimes": {
|
|
2046
|
+
"python": {
|
|
2047
|
+
"available": python_version.get("available", True),
|
|
2048
|
+
"executable": python_executable,
|
|
2049
|
+
"argvPrefix": [python_executable],
|
|
2050
|
+
"version": python_version.get("version") or sys.version.split()[0],
|
|
2051
|
+
"stdioEncoding": "utf-8",
|
|
2052
|
+
"filesystemEncoding": sys.getfilesystemencoding(),
|
|
2053
|
+
},
|
|
2054
|
+
"node": _optional_runtime("node"),
|
|
2055
|
+
"powershell": powershell,
|
|
2056
|
+
},
|
|
2057
|
+
"capabilities": {
|
|
2058
|
+
"jsonRoundTrip": json_round_trip,
|
|
2059
|
+
"argvArrays": True,
|
|
2060
|
+
"utf8NoBom": True,
|
|
2061
|
+
},
|
|
2062
|
+
}
|
|
2063
|
+
atomic_write_json(change_dir / "meta" / "runtime.json", payload)
|
|
2064
|
+
return {"ok": True, "action": "doctor", **payload}
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def cmd_quarantine_evidence(args: argparse.Namespace) -> dict[str, Any]:
|
|
2068
|
+
"""隔离命令:把明文敏感证据原子移出可发布树,逐条返回结果。
|
|
2069
|
+
|
|
2070
|
+
单条失败不吞掉其余条目——归档阻断项常常一次报多个文件,逐条报告才能一轮修完。
|
|
2071
|
+
"""
|
|
2072
|
+
project = Path(args.project).expanduser().resolve()
|
|
2073
|
+
change_root = Path(args.change_dir).expanduser().resolve()
|
|
2074
|
+
private_root = Path(args.private_root) if args.private_root else None
|
|
2075
|
+
entries: list[dict[str, Any]] = []
|
|
2076
|
+
for raw in args.file:
|
|
2077
|
+
candidate = Path(raw)
|
|
2078
|
+
source = candidate if candidate.is_absolute() else change_root / candidate
|
|
2079
|
+
outcome = quarantine_sensitive_evidence(
|
|
2080
|
+
source,
|
|
2081
|
+
change_root=change_root,
|
|
2082
|
+
reason=args.reason,
|
|
2083
|
+
private_root=private_root,
|
|
2084
|
+
project_root=project,
|
|
2085
|
+
)
|
|
2086
|
+
entries.append({"file": raw, **outcome})
|
|
2087
|
+
failed = [item for item in entries if not item.get("ok")]
|
|
2088
|
+
return {
|
|
2089
|
+
"ok": not failed,
|
|
2090
|
+
"action": "quarantine-evidence",
|
|
2091
|
+
"code": "SENSITIVE_EVIDENCE_QUARANTINED" if not failed else "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
|
|
2092
|
+
"quarantined": len(entries) - len(failed),
|
|
2093
|
+
"failed": len(failed),
|
|
2094
|
+
"entries": entries,
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2099
|
+
parser = argparse.ArgumentParser(prog="harness_runtime.py")
|
|
2100
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
2101
|
+
p_doctor = sub.add_parser("doctor")
|
|
2102
|
+
p_doctor.add_argument("--project", required=True)
|
|
2103
|
+
p_doctor.add_argument("--change-dir", required=True)
|
|
2104
|
+
p_doctor.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
|
|
2105
|
+
p_doctor.add_argument("--json", action="store_true")
|
|
2106
|
+
# 归档的 SENSITIVE_EVIDENCE_UNQUARANTINED 是硬阻断,此前却没有任何命令行入口
|
|
2107
|
+
# ——调用方只能写 python -c + sys.path hack 去调内部函数。
|
|
2108
|
+
p_quarantine = sub.add_parser(
|
|
2109
|
+
"quarantine-evidence",
|
|
2110
|
+
help="move plaintext sensitive evidence outside the publishable tree",
|
|
2111
|
+
)
|
|
2112
|
+
p_quarantine.add_argument("--project", required=True)
|
|
2113
|
+
p_quarantine.add_argument("--change-dir", required=True)
|
|
2114
|
+
p_quarantine.add_argument(
|
|
2115
|
+
"--file", required=True, action="append",
|
|
2116
|
+
help="path to quarantine, relative to --change-dir or absolute; repeatable",
|
|
2117
|
+
)
|
|
2118
|
+
p_quarantine.add_argument("--reason", default="sensitive evidence")
|
|
2119
|
+
p_quarantine.add_argument(
|
|
2120
|
+
"--private-root", default=None,
|
|
2121
|
+
help="override the private root; must be outside the project root and on its drive",
|
|
2122
|
+
)
|
|
2123
|
+
p_quarantine.add_argument("--json", action="store_true")
|
|
2124
|
+
p_sweep = sub.add_parser(
|
|
2125
|
+
"sweep-scratch",
|
|
2126
|
+
help="删除 runtime/ 顶层的过程草稿(diff、临时输入、命令输出)",
|
|
2127
|
+
)
|
|
2128
|
+
p_sweep.add_argument("--change-dir", required=True)
|
|
2129
|
+
p_sweep.add_argument(
|
|
2130
|
+
"--dry-run", action="store_true", help="只列出会删什么,不真删"
|
|
2131
|
+
)
|
|
2132
|
+
p_sweep.add_argument("--json", action="store_true")
|
|
2133
|
+
p_adapter = sub.add_parser("adapter")
|
|
2134
|
+
p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
|
|
2135
|
+
p_adapter.add_argument("--change", required=True)
|
|
2136
|
+
p_adapter.add_argument("--json", action="store_true")
|
|
2137
|
+
p_start = sub.add_parser("run-start", help="start a managed background run")
|
|
2138
|
+
p_start.add_argument("--state-root", required=True)
|
|
2139
|
+
p_start.add_argument("--verification", required=True)
|
|
2140
|
+
p_start.add_argument("--working-directory", required=True)
|
|
2141
|
+
p_start.add_argument("--timeout-seconds", type=float)
|
|
2142
|
+
p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
|
|
2143
|
+
p_start.add_argument("--expected-duration-seconds", type=float)
|
|
2144
|
+
p_start.add_argument("--product-identity")
|
|
2145
|
+
p_start.add_argument("--resource-lock", action="append", default=[])
|
|
2146
|
+
p_start.add_argument("--json", action="store_true")
|
|
2147
|
+
p_start.add_argument("argv", nargs=argparse.REMAINDER)
|
|
2148
|
+
p_status = sub.add_parser("run-status", help="read a managed run receipt")
|
|
2149
|
+
p_status.add_argument("--state-root", required=True)
|
|
2150
|
+
p_status.add_argument("--session-id", required=True)
|
|
2151
|
+
p_status.add_argument(
|
|
2152
|
+
"--wait",
|
|
2153
|
+
action="store_true",
|
|
2154
|
+
help="阻塞到会话进入终态再返回,替代调用方自己 sleep 猜时长",
|
|
2155
|
+
)
|
|
2156
|
+
p_status.add_argument("--wait-timeout-seconds", type=float, default=600.0)
|
|
2157
|
+
p_status.add_argument("--poll-seconds", type=float, default=2.0)
|
|
2158
|
+
p_status.add_argument("--json", action="store_true")
|
|
2159
|
+
p_log = sub.add_parser("run-log", help="read an incremental log page")
|
|
2160
|
+
p_log.add_argument("--state-root", required=True)
|
|
2161
|
+
p_log.add_argument("--session-id", required=True)
|
|
2162
|
+
p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
|
|
2163
|
+
p_log.add_argument("--cursor", type=int, default=0)
|
|
2164
|
+
p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
|
|
2165
|
+
p_log.add_argument("--json", action="store_true")
|
|
2166
|
+
p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
|
|
2167
|
+
p_cancel.add_argument("--state-root", required=True)
|
|
2168
|
+
p_cancel.add_argument("--session-id", required=True)
|
|
2169
|
+
p_cancel.add_argument("--json", action="store_true")
|
|
2170
|
+
p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
|
|
2171
|
+
p_worker.add_argument("--state-root", required=True)
|
|
2172
|
+
p_worker.add_argument("--session-id", required=True)
|
|
2173
|
+
return parser
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2177
|
+
args = build_parser().parse_args(argv)
|
|
2178
|
+
try:
|
|
2179
|
+
if args.command == "doctor":
|
|
2180
|
+
result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
|
|
2181
|
+
elif args.command == "quarantine-evidence":
|
|
2182
|
+
result = cmd_quarantine_evidence(args)
|
|
2183
|
+
elif args.command == "sweep-scratch":
|
|
2184
|
+
result = sweep_scratch(
|
|
2185
|
+
Path(args.change_dir), dry_run=bool(args.dry_run)
|
|
2186
|
+
)
|
|
2187
|
+
elif args.command == "adapter":
|
|
2188
|
+
result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
|
|
2189
|
+
elif args.command == "_worker":
|
|
2190
|
+
return _run_session_worker_with_log(
|
|
2191
|
+
Path(args.state_root),
|
|
2192
|
+
args.session_id,
|
|
2193
|
+
)
|
|
2194
|
+
elif args.command == "run-start":
|
|
2195
|
+
command_argv = list(args.argv)
|
|
2196
|
+
if command_argv and command_argv[0] == "--":
|
|
2197
|
+
command_argv = command_argv[1:]
|
|
2198
|
+
result = start_run_session(
|
|
2199
|
+
state_root=Path(args.state_root),
|
|
2200
|
+
verification=args.verification,
|
|
2201
|
+
argv=command_argv,
|
|
2202
|
+
working_directory=Path(args.working_directory),
|
|
2203
|
+
timeout_seconds=args.timeout_seconds,
|
|
2204
|
+
heartbeat_seconds=args.heartbeat_seconds,
|
|
2205
|
+
expected_duration_seconds=args.expected_duration_seconds,
|
|
2206
|
+
product_identity=args.product_identity,
|
|
2207
|
+
resource_locks=list(args.resource_lock),
|
|
2208
|
+
)
|
|
2209
|
+
elif args.command == "run-status":
|
|
2210
|
+
if getattr(args, "wait", False):
|
|
2211
|
+
result = await_run_session(
|
|
2212
|
+
Path(args.state_root),
|
|
2213
|
+
args.session_id,
|
|
2214
|
+
timeout_seconds=args.wait_timeout_seconds,
|
|
2215
|
+
poll_seconds=args.poll_seconds,
|
|
2216
|
+
)
|
|
2217
|
+
else:
|
|
2218
|
+
result = run_session_status(Path(args.state_root), args.session_id)
|
|
2219
|
+
elif args.command == "run-log":
|
|
2220
|
+
result = read_run_session_log(
|
|
2221
|
+
Path(args.state_root),
|
|
2222
|
+
args.session_id,
|
|
2223
|
+
stream=args.stream,
|
|
2224
|
+
cursor=args.cursor,
|
|
2225
|
+
max_bytes=args.max_bytes,
|
|
2226
|
+
)
|
|
2227
|
+
else:
|
|
2228
|
+
result = cancel_run_session(Path(args.state_root), args.session_id)
|
|
2229
|
+
except (OSError, ValueError) as exc:
|
|
2230
|
+
code = str(exc).split(":", 1)[0]
|
|
2231
|
+
result = {"ok": False, "code": code, "error": str(exc)}
|
|
2232
|
+
stream = sys.stderr
|
|
2233
|
+
stream.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
2234
|
+
return 1
|
|
2235
|
+
if getattr(args, "json", False):
|
|
2236
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
|
2237
|
+
else:
|
|
2238
|
+
sys.stdout.write(str(result.get("action")) + "\n")
|
|
2239
|
+
return 0
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
if __name__ == "__main__":
|
|
2243
|
+
raise SystemExit(main())
|