@piagent/platform 1.3.1 → 1.4.1
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/CHANGELOG.md +132 -0
- package/README.md +10 -4
- package/README.vi.md +27 -0
- package/architecture/layers.json +96 -1
- package/docs/README.md +1 -0
- package/docs/architecture.md +1 -0
- package/docs/authority-manifest.md +1 -0
- package/docs/auto-delegation-policy.md +1 -0
- package/docs/capability-packs.md +1 -0
- package/docs/command-reference-vietnamese.md +5 -4
- package/docs/context-engine.md +1 -0
- package/docs/context-window-policy.md +1 -0
- package/docs/distribution-standard.md +8 -7
- package/docs/en/architecture.md +13 -0
- package/docs/herdr-workflow.md +1 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +125 -9
- package/docs/memory-policy.md +1 -0
- package/docs/model-options.md +1 -0
- package/docs/oauth-providers.md +1 -0
- package/docs/operator-manual-vietnamese.md +15 -12
- package/docs/package-architecture-notes.md +1 -0
- package/docs/project-adapters.md +1 -0
- package/docs/project-onboarding.md +1 -0
- package/docs/publishing-for-teams.md +37 -0
- package/docs/quality-benchmark.md +1 -0
- package/docs/quickstart-vietnamese.md +3 -2
- package/docs/release-install-policy.md +22 -8
- package/docs/runtime-harness-standard.md +2 -1
- package/docs/runtime-policy-design.md +1 -0
- package/docs/runtime-quality-baseline.md +1 -0
- package/docs/security-redaction-benchmark.md +1 -0
- package/docs/security-threat-model.md +2 -1
- package/docs/subagent-orchestration-capabilities.md +1 -0
- package/docs/subagents-and-multiagent.md +1 -0
- package/docs/task-implementation-contract.md +12 -4
- package/docs/task-lifecycle-vietnamese.md +1 -0
- package/docs/team-onboarding.md +3 -2
- package/docs/third-party-neutrality.md +26 -0
- package/docs/usage-observability.md +71 -0
- package/docs/vercel-docs-site.md +1 -0
- package/docs/vi/architecture.md +13 -0
- package/docs/workflow-recipes.md +1 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -1
- package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
- package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
- package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
- package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
- package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
- package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
- package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
- package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
- package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
- package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
- package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
- package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
- package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
- package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
- package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
- package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
- package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
- package/package.json +43 -4
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
- package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
- package/packages/piagent-core/capabilities/capability-core.js +2 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +190 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +80 -272
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/extensions/task-journal.js +30 -1
- package/packages/piagent-core/extensions/task-state.js +33 -36
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +139 -9
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +12 -2
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
- package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
- package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
- package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
- package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
- package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
- package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
- package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
- package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
- package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
- package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
- package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
- package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
- package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
- package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
- package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
- package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
- package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
- package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
- package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
- package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
- package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
- package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
- package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
- package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
- package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
- package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
- package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
- package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
- package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
- package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +93 -6
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
- package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
- package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
- package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
- package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +22 -27
- package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
- package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
- package/packages/piagent-webui/dist/client/index.html +16 -0
- package/packages/piagent-webui/extension/attachment-store.ts +323 -0
- package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
- package/packages/piagent-webui/extension/lifecycle-controller.ts +423 -0
- package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
- package/packages/piagent-webui/extension/review-controller.ts +160 -0
- package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
- package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
- package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
- package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
- package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
- package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
- package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
- package/packages/piagent-webui/gateway/control-socket.ts +92 -0
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
- package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
- package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
- package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
- package/packages/piagent-webui/gateway/pi-host.ts +41 -0
- package/packages/piagent-webui/gateway/profile-state.ts +1 -0
- package/packages/piagent-webui/gateway/project-registry.ts +108 -0
- package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
- package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
- package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +239 -0
- package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
- package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +489 -0
- package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
- package/packages/piagent-webui/ownership/profile-state.ts +117 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
- package/packages/piagent-webui/ownership/session-refs.ts +8 -0
- package/packages/piagent-webui/package.json +33 -0
- package/packages/piagent-webui/server/README.md +4 -0
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
- package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
- package/packages/piagent-webui/server/http-security.ts +27 -0
- package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
- package/packages/piagent-webui/server/loopback-server.ts +368 -0
- package/packages/piagent-webui/server/read-model-provider.ts +37 -0
- package/packages/piagent-webui/server/read-only-router.ts +105 -0
- package/packages/piagent-webui/server/session-auth.ts +88 -0
- package/packages/piagent-webui/server/sidecar-main.ts +86 -0
- package/packages/piagent-webui/server/sse-hub.ts +76 -0
- package/packages/piagent-webui/server/static-bundle.ts +40 -0
- package/packages/piagent-webui/server/transcript-projection.ts +125 -0
- package/schemas/mutation-provenance-record.schema.json +100 -0
- package/schemas/piagent-webui/approval-v1.schema.json +654 -0
- package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
- package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
- package/schemas/piagent-webui/catalog-v1.json +240 -0
- package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
- package/schemas/piagent-webui/common-v1.schema.json +479 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
- package/schemas/piagent-webui/diff-v1.schema.json +826 -0
- package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
- package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
- package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
- package/schemas/piagent-webui/queue-v1.schema.json +99 -0
- package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
- package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
- package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
- package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
- package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
- package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
- package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
- package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
- package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
- package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
- package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
- package/schemas/task-baseline-manifest.schema.json +122 -0
- package/schemas/verifier-file-snapshot.schema.json +81 -0
- package/scripts/build-docs-site.mjs +21 -1
- package/scripts/check-architecture.mjs +1 -0
- package/scripts/check-doc-languages.mjs +19 -2
- package/scripts/check-third-party-neutrality.mjs +43 -0
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +105 -4
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +30 -3
- package/scripts/piagent-dashboard.mjs +261 -0
- package/scripts/piagent-webui-launcher.mjs +121 -0
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +42 -1
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +257 -0
- package/scripts/verify-release-identity.mjs +4 -1
- package/scripts/verify-translation-pairing.mjs +121 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// What to do about a refusal.
|
|
2
|
+
//
|
|
3
|
+
// A blocked tool call is the only conversation most operators ever have with
|
|
4
|
+
// this policy, and for most of them it was one-sided: the guard states 74
|
|
5
|
+
// distinct refusals and three of them said what to do next. The rest ended at
|
|
6
|
+
// "no", which leaves the reader guessing at a rule they cannot see.
|
|
7
|
+
//
|
|
8
|
+
// Remedies are attached in one place rather than at the 29 sites that build a
|
|
9
|
+
// refusal, for the reason this codebase keeps arriving at: a rule applied at
|
|
10
|
+
// every call site is a rule that will be missed at the next one. Every decision
|
|
11
|
+
// leaves through `registerToolCallHook`, so it is enough to answer there, and a
|
|
12
|
+
// refusal added tomorrow is answered without anyone remembering to.
|
|
13
|
+
|
|
14
|
+
// Matched against the refusal text in order; the first match wins, so put the
|
|
15
|
+
// narrower phrase above the broader one it would otherwise be swallowed by.
|
|
16
|
+
const REMEDIES: Array<{ match: RegExp; remedy: string }> = [
|
|
17
|
+
{
|
|
18
|
+
// `grep glob targeting` and `find pattern targeting` are the same refusal
|
|
19
|
+
// reached through a different tool, and matching only the shell wording left
|
|
20
|
+
// two thirds of this family answering nothing.
|
|
21
|
+
match: /glob can target protected path|glob targeting protected path|pattern targeting protected path/i,
|
|
22
|
+
remedy: "Name the exact file instead of a pattern: a pattern is refused when any name it can match is protected."
|
|
23
|
+
+ " `piagent explain '<command>'` shows which name it matched."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
match: /builds a filename this guard cannot resolve/i,
|
|
27
|
+
// This refusal already carries its own guidance, so it only gains the
|
|
28
|
+
// pointer to the tool that shows the whole chain.
|
|
29
|
+
remedy: "`piagent explain '<command>'` shows every step that ran and what each one saw."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
match: /(touches|resolves to) protected path|access to protected path|write to protected path/i,
|
|
33
|
+
remedy: "Use an approved `piagent-context` projection if one is available. Direct access requires the operator"
|
|
34
|
+
+ " to change the protected-path policy; task scope alone does not override it."
|
|
35
|
+
+ " `piagent explain '<command>'` shows which pattern matched."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
match: /write to read-only path/i,
|
|
39
|
+
remedy: "The active profile marks this path read-only. Change `readOnlyPaths` in `.pi/piagent-profile.json`"
|
|
40
|
+
+ " if the project really should allow writes here, then reapply the profile."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
match: /outside resolved filesystem scope/i,
|
|
44
|
+
remedy: "Work inside the project, or widen the filesystem scope in the active profile and reapply it."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
match: /cannot mutate paths outside its declared scope|semantic repair must stay inside its declared task scope/i,
|
|
48
|
+
remedy: "Start a new attempt with `piagent_task_start` listing this path in the scope; a running task cannot widen its own."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
match: /Task Implementation Contract is required/i,
|
|
52
|
+
remedy: "Call `piagent_task_start` once with explicit project-relative scope, then retry."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
match: /Capability lock is (missing|unreadable)|does not match what this project agreed to/i,
|
|
56
|
+
remedy: "Reapply the project profile to rebuild the lock: `piagent-init <project> --force-profile`."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
match: /runtime changed during this session/i,
|
|
60
|
+
remedy: "Restart the Pi session. The lock is re-verified and re-pinned at session start, never mid-session."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
match: /Permission profile read-only blocked/i,
|
|
64
|
+
remedy: "This session is read-only. Change the access level in Settings, or start it with a writable permission profile."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
match: /Tool (is not registered|registry is disabled|registry blocked)/i,
|
|
68
|
+
remedy: "Enable the tool group for this profile, or use a tool the profile already allows."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
match: /lifecycle control blocks tool start|resume is blocked|could not enter an audited repair phase/i,
|
|
72
|
+
remedy: "Resolve the task lifecycle first — resume, repair, or close the current attempt — then retry."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
match: /path traverses symbolic link|path resolves outside the project|path component cannot be inspected/i,
|
|
76
|
+
remedy: "Use the real path inside the project. A link that leaves the project is refused rather than followed."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
match: /requires confirmation|requires explicit operator approval/i,
|
|
80
|
+
remedy: "Approve it in the session when prompted; this is a decision the operator makes, not a defect."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
// The whole session is refused here, so this is the refusal most in need of
|
|
84
|
+
// a next step -- and the one the narrower MCP wording used to miss.
|
|
85
|
+
match: /Blocked every tool call/i,
|
|
86
|
+
remedy: "Every tool call is refused while a repository config can reach an ungated server."
|
|
87
|
+
+ " Run `piagent-mcp doctor` to see which config stopped the session, then approve or remove those servers."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
match: /MCP (proxy|shell carrier|command|server)/i,
|
|
91
|
+
remedy: "Check the server with `piagent-mcp doctor`, and approve it explicitly if the project should reach it."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
match: /is read-only; .*cannot mutate|read-only inspection allowlist/i,
|
|
95
|
+
remedy: "This task was started read-only. Start a source-change task with `piagent_task_start` when the work needs to write."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
match: /opaque shell mutation whose write target is not statically bounded/i,
|
|
99
|
+
remedy: "Use the edit, write, or apply-patch tools so the target is named, instead of a shell command that computes it."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
match: /Context budget blocked editing large file/i,
|
|
103
|
+
remedy: "Edit a narrower range of the file, or split the change, so the edit stays inside the context budget."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
match: /Approval became stale/i,
|
|
107
|
+
remedy: "Ask for the action again; an approval is bound to the state it was granted for and does not survive a change to it."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
match: /Missing capability|Capability validation failed/i,
|
|
111
|
+
remedy: "Add the pack that provides it to the project profile, then reapply the profile."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
match: /nesting exceeds inspection depth|unsafe scope pattern/i,
|
|
115
|
+
remedy: "Flatten the tool input or narrow the scope pattern: anything the guard cannot finish inspecting is refused rather than guessed at."
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
match: /start a new attempt or a fresh task/i,
|
|
119
|
+
remedy: "Start the next attempt with `piagent_task_start`; a closed attempt cannot keep mutating the project."
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// Kept out of the appended text when the refusal already ends by saying what to
|
|
124
|
+
// do, so a reader is never given the same instruction twice in one line.
|
|
125
|
+
const ALREADY_GUIDED = /Write the path out|Reapply the project profile|Restart the session|then retry\.$|Start the task first/i;
|
|
126
|
+
|
|
127
|
+
export function remedyForReason(reason: string): string | undefined {
|
|
128
|
+
if (typeof reason !== "string" || reason.trim().length === 0) return undefined;
|
|
129
|
+
const found = REMEDIES.find((entry) => entry.match.test(reason));
|
|
130
|
+
if (!found) return undefined;
|
|
131
|
+
if (ALREADY_GUIDED.test(reason) && !/glob can target|cannot resolve/i.test(reason)) return undefined;
|
|
132
|
+
return found.remedy;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function withRemedy<T extends { block: true; reason: string }>(decision: T): T {
|
|
136
|
+
const remedy = remedyForReason(decision.reason);
|
|
137
|
+
if (!remedy || decision.reason.includes(remedy)) return decision;
|
|
138
|
+
return { ...decision, reason: `${decision.reason.replace(/\s+$/, "")} → ${remedy}` };
|
|
139
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { runReadOnlyGit } from "../inspection/git-status-adapter.ts";
|
|
8
|
+
import {
|
|
9
|
+
collectIndexPreimage,
|
|
10
|
+
collectSelectedWorkspacePreimage,
|
|
11
|
+
type SourceMutationAuthority
|
|
12
|
+
} from "../inspection/source-mutation-projection.ts";
|
|
13
|
+
import { localFilterDisableArgs } from "../inspection/git-filter-safety.ts";
|
|
14
|
+
|
|
15
|
+
const MAX_INDEX_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export type SourceIndexTransactionResult = {
|
|
18
|
+
state: "settled" | "rejected" | "uncertain";
|
|
19
|
+
reasonCode: string | null;
|
|
20
|
+
beforeIndexPreimage: string;
|
|
21
|
+
afterIndexPreimage: string | null;
|
|
22
|
+
beforeWorkspacePreimage: string;
|
|
23
|
+
afterWorkspacePreimage: string | null;
|
|
24
|
+
executor: "pi-guard";
|
|
25
|
+
directExecution: false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function result(value: Omit<SourceIndexTransactionResult, "executor" | "directExecution">): SourceIndexTransactionResult {
|
|
29
|
+
return { ...value, executor: "pi-guard", directExecution: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitEnvironment(indexFile: string): NodeJS.ProcessEnv {
|
|
33
|
+
return { PATH: process.env.PATH, LC_ALL: "C", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
|
|
34
|
+
GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GIT_INDEX_FILE: indexFile,
|
|
35
|
+
...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec } : {}) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runGitMutation(repoRoot: string, indexFile: string, args: string[], timeoutMs: number, input?: Buffer): Promise<Buffer> {
|
|
39
|
+
const filterArgs = await localFilterDisableArgs(repoRoot, timeoutMs);
|
|
40
|
+
const argv = ["--no-pager", "--literal-pathspecs", "-c", "color.ui=false", "-c", "core.fsmonitor=false", `-c`, `core.hooksPath=${os.devNull}`,
|
|
41
|
+
"-c", "diff.external=", "-c", "submodule.recurse=false", "-C", repoRoot, ...args];
|
|
42
|
+
argv.splice(argv.indexOf("-C"), 0, ...filterArgs);
|
|
43
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
44
|
+
const child = spawn("git", argv, { cwd: repoRoot, env: gitEnvironment(indexFile), shell: false, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true });
|
|
45
|
+
const stdout: Buffer[] = []; let stdoutBytes = 0, stderrBytes = 0, settled = false;
|
|
46
|
+
child.stdout.on("data", (chunk: Buffer) => { stdoutBytes += chunk.length; if (stdoutBytes <= 64 * 1024) stdout.push(chunk); else child.kill("SIGKILL"); });
|
|
47
|
+
child.stderr.on("data", (chunk: Buffer) => { stderrBytes += chunk.length; if (stderrBytes > 64 * 1024) child.kill("SIGKILL"); });
|
|
48
|
+
if (input) { child.stdin.on("error", () => undefined); child.stdin.end(input); }
|
|
49
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); timer.unref?.();
|
|
50
|
+
child.once("error", () => { clearTimeout(timer); if (!settled) { settled = true; reject(new Error("git-mutation-start-failed")); } });
|
|
51
|
+
child.once("close", (code) => { clearTimeout(timer); if (settled) return; settled = true;
|
|
52
|
+
if (code === 0 && stdoutBytes <= 64 * 1024 && stderrBytes <= 64 * 1024) resolve(Buffer.concat(stdout)); else reject(new Error("git-mutation-failed")); });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function stageExactPaths(repoRoot: string, indexFile: string, repoPaths: string[], timeoutMs: number, objectLengthHint: number): Promise<void> {
|
|
57
|
+
const entries: Array<{ mode: string; object: string | null; repoPath: string }> = [];
|
|
58
|
+
let objectLength: 40 | 64 | null = null;
|
|
59
|
+
for (const repoPath of repoPaths) {
|
|
60
|
+
const absolute = path.resolve(repoRoot, repoPath);
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.lstatSync(absolute);
|
|
63
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsupported-workspace-entry");
|
|
64
|
+
const object = (await runGitMutation(repoRoot, indexFile, ["hash-object", "-w", "--no-filters", "--", repoPath], timeoutMs)).toString("ascii").trim();
|
|
65
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(object)) throw new Error("git-object-id-invalid");
|
|
66
|
+
objectLength = object.length as 40 | 64;
|
|
67
|
+
const mode = stat.mode & 0o111 ? "100755" : "100644";
|
|
68
|
+
entries.push({ mode, object, repoPath });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
71
|
+
entries.push({ mode: "0", object: null, repoPath });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const length = objectLength ?? (objectLengthHint === 64 ? 64 : 40);
|
|
75
|
+
const input = Buffer.concat(entries.map((entry) => Buffer.from(`${entry.mode} ${entry.object ?? "0".repeat(length)}\t${entry.repoPath}\0`)));
|
|
76
|
+
await runGitMutation(repoRoot, indexFile, ["update-index", "-z", "--index-info"], timeoutMs, input);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function applySelectedHunks(authority: SourceMutationAuthority, indexFile: string, selectedHunkRefs: string[], timeoutMs: number): Promise<void> {
|
|
80
|
+
const patch = authority.patchAuthority;
|
|
81
|
+
if (!patch || selectedHunkRefs.length === 0 || selectedHunkRefs.length > 128) throw new Error("hunk-patch-unavailable");
|
|
82
|
+
const selected = new Set(selectedHunkRefs), blocks = patch.hunks.filter((hunk) => selected.has(hunk.hunkRef));
|
|
83
|
+
if (blocks.length !== selected.size) throw new Error("hunk-patch-stale");
|
|
84
|
+
const bytes = Buffer.concat([patch.header, ...blocks.map((hunk) => hunk.bytes)]);
|
|
85
|
+
if (bytes.length === 0 || bytes.length > 2 * 1024 * 1024) throw new Error("hunk-patch-oversized");
|
|
86
|
+
await runGitMutation(authority.repoRoot, indexFile, ["apply", "--cached", "--recount", "--whitespace=nowarn",
|
|
87
|
+
...(authority.target.view === "staged" ? ["--reverse"] : []), "-"], timeoutMs, bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function gitDirectory(repoRoot: string): Promise<string> {
|
|
91
|
+
const output = await runReadOnlyGit(repoRoot, ["rev-parse", "--absolute-git-dir"], { maxBytes: 16 * 1024 });
|
|
92
|
+
const candidate = output.toString("utf8").trim();
|
|
93
|
+
if (!path.isAbsolute(candidate) || candidate.includes("\0")) throw new Error("git-directory-invalid");
|
|
94
|
+
const resolved = fs.realpathSync.native(candidate), stat = fs.lstatSync(resolved);
|
|
95
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("git-directory-invalid");
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function copyIndex(indexPath: string, temporary: string): void {
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.lstatSync(indexPath);
|
|
102
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_INDEX_BYTES) throw new Error("git-index-invalid");
|
|
103
|
+
fs.copyFileSync(indexPath, temporary, fs.constants.COPYFILE_EXCL); fs.chmodSync(temporary, 0o600);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function publishIndex(lockDescriptor: number, lockPath: string, temporary: string, indexPath: string): void {
|
|
110
|
+
const stat = fs.lstatSync(temporary);
|
|
111
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_INDEX_BYTES) throw new Error("temporary-index-invalid");
|
|
112
|
+
const bytes = fs.readFileSync(temporary); fs.ftruncateSync(lockDescriptor, 0);
|
|
113
|
+
let offset = 0;
|
|
114
|
+
while (offset < bytes.length) offset += fs.writeSync(lockDescriptor, bytes, offset, bytes.length - offset, offset);
|
|
115
|
+
fs.fsyncSync(lockDescriptor); fs.fchmodSync(lockDescriptor, 0o600); fs.closeSync(lockDescriptor); fs.renameSync(lockPath, indexPath);
|
|
116
|
+
try { const directory = fs.openSync(path.dirname(indexPath), "r"); try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); } } catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function executeGuardedSourceIndexTransaction(options: {
|
|
120
|
+
authority: SourceMutationAuthority;
|
|
121
|
+
expectedIndexPreimage: string;
|
|
122
|
+
expectedWorkspacePreimage: string;
|
|
123
|
+
selectedHunkRefs?: string[];
|
|
124
|
+
recheck: () => boolean;
|
|
125
|
+
timeoutMs?: number;
|
|
126
|
+
}): Promise<SourceIndexTransactionResult> {
|
|
127
|
+
const before = { beforeIndexPreimage: options.expectedIndexPreimage, beforeWorkspacePreimage: options.expectedWorkspacePreimage };
|
|
128
|
+
let directory: string;
|
|
129
|
+
try { directory = await gitDirectory(options.authority.repoRoot); }
|
|
130
|
+
catch { return result({ state: "rejected", reasonCode: "git-directory-unavailable", ...before, afterIndexPreimage: null, afterWorkspacePreimage: null }); }
|
|
131
|
+
const indexPath = path.join(directory, "index"), lockPath = path.join(directory, "index.lock");
|
|
132
|
+
const temporary = path.join(directory, `piagent-guard-index.${process.pid}.${randomBytes(12).toString("hex")}`);
|
|
133
|
+
let lockDescriptor: number | null = null, ownsLock = false, committed = false;
|
|
134
|
+
try {
|
|
135
|
+
lockDescriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
136
|
+
ownsLock = true;
|
|
137
|
+
const lockedIndex = await collectIndexPreimage(options.authority.repoRoot);
|
|
138
|
+
const lockedWorkspace = collectSelectedWorkspacePreimage(options.authority.repoRoot, options.authority.repoPaths);
|
|
139
|
+
if (!options.recheck() || lockedIndex !== options.expectedIndexPreimage || lockedWorkspace !== options.expectedWorkspacePreimage) {
|
|
140
|
+
return result({ state: "rejected", reasonCode: "mutation-preimage-stale", ...before,
|
|
141
|
+
afterIndexPreimage: lockedIndex, afterWorkspacePreimage: lockedWorkspace });
|
|
142
|
+
}
|
|
143
|
+
copyIndex(indexPath, temporary);
|
|
144
|
+
const timeoutMs = Math.max(100, Math.min(60_000, options.timeoutMs ?? 5_000));
|
|
145
|
+
if ((options.selectedHunkRefs?.length ?? 0) > 0) await applySelectedHunks(options.authority, temporary, options.selectedHunkRefs!, timeoutMs);
|
|
146
|
+
else if (options.authority.target.view === "working-tree") await stageExactPaths(options.authority.repoRoot, temporary,
|
|
147
|
+
options.authority.repoPaths, timeoutMs, options.authority.headOid?.length ?? 40);
|
|
148
|
+
else if (options.authority.headOid) await runGitMutation(options.authority.repoRoot, temporary,
|
|
149
|
+
["restore", "--staged", "--source=HEAD", "--", ...options.authority.repoPaths], timeoutMs);
|
|
150
|
+
else await runGitMutation(options.authority.repoRoot, temporary,
|
|
151
|
+
["rm", "--cached", "-f", "--ignore-unmatch", "--", ...options.authority.repoPaths], timeoutMs);
|
|
152
|
+
const finalWorkspace = collectSelectedWorkspacePreimage(options.authority.repoRoot, options.authority.repoPaths);
|
|
153
|
+
const finalRealIndex = await collectIndexPreimage(options.authority.repoRoot);
|
|
154
|
+
if (!options.recheck() || finalWorkspace !== options.expectedWorkspacePreimage || finalRealIndex !== options.expectedIndexPreimage) {
|
|
155
|
+
return result({ state: "rejected", reasonCode: "mutation-preimage-stale", ...before,
|
|
156
|
+
afterIndexPreimage: finalRealIndex, afterWorkspacePreimage: finalWorkspace });
|
|
157
|
+
}
|
|
158
|
+
publishIndex(lockDescriptor, lockPath, temporary, indexPath); lockDescriptor = null; ownsLock = false; committed = true;
|
|
159
|
+
const afterIndexPreimage = await collectIndexPreimage(options.authority.repoRoot);
|
|
160
|
+
const afterWorkspacePreimage = collectSelectedWorkspacePreimage(options.authority.repoRoot, options.authority.repoPaths);
|
|
161
|
+
if (afterWorkspacePreimage !== options.expectedWorkspacePreimage || afterIndexPreimage === options.expectedIndexPreimage) {
|
|
162
|
+
return result({ state: "uncertain", reasonCode: "mutation-postcondition-unknown", ...before, afterIndexPreimage, afterWorkspacePreimage });
|
|
163
|
+
}
|
|
164
|
+
return result({ state: "settled", reasonCode: null, ...before, afterIndexPreimage, afterWorkspacePreimage });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const reasonCode = (error as NodeJS.ErrnoException).code === "EEXIST" ? "git-index-locked" : committed ? "mutation-postcondition-unknown" : "git-mutation-failed";
|
|
167
|
+
return result({ state: committed ? "uncertain" : "rejected", reasonCode, ...before, afterIndexPreimage: null, afterWorkspacePreimage: null });
|
|
168
|
+
} finally {
|
|
169
|
+
if (lockDescriptor !== null) try { fs.closeSync(lockDescriptor); } catch {}
|
|
170
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
171
|
+
if (ownsLock) try { fs.rmSync(lockPath, { force: true }); } catch {}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { effectiveProtectedPaths } from "../../extensions/context-index-policy.js";
|
|
5
|
+
import type { BasePolicy, ProjectProfile, TaskContract } from "../../extensions/guard-types.js";
|
|
6
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
7
|
+
import { activeSessionTask } from "../../extensions/task-state.js";
|
|
8
|
+
import { inspectTaskControlState } from "../inspection/task-control-journal.ts";
|
|
9
|
+
import { webUiTaskRevision } from "../inspection/webui-snapshot.ts";
|
|
10
|
+
import { piSourceMutationGuard } from "./source-mutation-guard.ts";
|
|
11
|
+
|
|
12
|
+
export type SourceMutationGuardBindings = {
|
|
13
|
+
bind(ctx: ExtensionContext): void;
|
|
14
|
+
unbind(ctx: ExtensionContext): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createSourceMutationGuardBindings(policy: BasePolicy,
|
|
18
|
+
loadProfile: (ctx: ExtensionContext) => ProjectProfile): SourceMutationGuardBindings {
|
|
19
|
+
const guardInstanceId = `guard.${randomBytes(32).toString("hex")}`, bindings = new Map<string, () => void>();
|
|
20
|
+
const key = (ctx: ExtensionContext) => `${ctx.cwd}\0${ctx.sessionManager.getSessionId()}`;
|
|
21
|
+
return {
|
|
22
|
+
bind(ctx) {
|
|
23
|
+
const bindingKey = key(ctx); bindings.get(bindingKey)?.();
|
|
24
|
+
bindings.set(bindingKey, piSourceMutationGuard.bind({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), guardInstanceId,
|
|
25
|
+
facts: () => {
|
|
26
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
27
|
+
if (!task) return null;
|
|
28
|
+
const control = inspectTaskControlState(ctx.cwd, task);
|
|
29
|
+
const taskState = task.trace.outcome !== "pending" || control.state === "terminal" ? "terminal"
|
|
30
|
+
: control.state === "active" ? "active" : "unknown";
|
|
31
|
+
const protectedPaths = effectiveProtectedPaths(policy, loadProfile(ctx)).readProtectedPaths;
|
|
32
|
+
return { taskId: task.taskId, taskRunId: task.taskRunId, taskRevision: webUiTaskRevision(task), controlRevision: control.controlRevision,
|
|
33
|
+
taskState, idle: ctx.isIdle(), isProtectedPath: (candidate: string) => matchesProtectedPath(candidate, protectedPaths) };
|
|
34
|
+
} }));
|
|
35
|
+
},
|
|
36
|
+
unbind(ctx) {
|
|
37
|
+
const bindingKey = key(ctx), unbind = bindings.get(bindingKey);
|
|
38
|
+
if (!unbind) return;
|
|
39
|
+
bindings.delete(bindingKey); unbind();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
validateSourceMutationTarget,
|
|
6
|
+
type SourceMutationAction,
|
|
7
|
+
type SourceMutationAuthority
|
|
8
|
+
} from "../inspection/source-mutation-projection.ts";
|
|
9
|
+
import { validateSourceRevertTarget, type SourceRevertAuthority } from "../inspection/source-revert-projection.ts";
|
|
10
|
+
import {
|
|
11
|
+
executeGuardedSourceIndexTransaction,
|
|
12
|
+
type SourceIndexTransactionResult
|
|
13
|
+
} from "./source-index-transaction.ts";
|
|
14
|
+
import { executeGuardedSourceWorktreeRevert } from "./source-worktree-transaction.ts";
|
|
15
|
+
|
|
16
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
17
|
+
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
|
|
18
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
19
|
+
const WORKSPACE = /^wt-content-v2:[a-f0-9]{64}$/;
|
|
20
|
+
|
|
21
|
+
export type SourceMutationGuardFacts = {
|
|
22
|
+
taskId: string;
|
|
23
|
+
taskRunId: string;
|
|
24
|
+
taskRevision: string;
|
|
25
|
+
controlRevision: string;
|
|
26
|
+
taskState: "active" | "terminal" | "unknown";
|
|
27
|
+
idle: boolean;
|
|
28
|
+
isProtectedPath(projectRelativePath: string): boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SourceMutationGuardRequest = {
|
|
32
|
+
cwd: string;
|
|
33
|
+
rawSessionId: string;
|
|
34
|
+
identity: { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string };
|
|
35
|
+
action: SourceMutationAction;
|
|
36
|
+
expectedTaskRevision: string;
|
|
37
|
+
expectedControlRevision: string;
|
|
38
|
+
expectedIndexPreimage: string;
|
|
39
|
+
expectedWorkspacePreimage: string;
|
|
40
|
+
selectedHunkRefs: string[];
|
|
41
|
+
authority: SourceMutationAuthority;
|
|
42
|
+
};
|
|
43
|
+
export type SourceRevertGuardRequest = {
|
|
44
|
+
cwd: string; rawSessionId: string;
|
|
45
|
+
identity: { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string };
|
|
46
|
+
action: "source.revert"; expectedTaskRevision: string; expectedControlRevision: string; expectedIndexPreimage: string;
|
|
47
|
+
expectedWorkspacePreimage: string; previewRef: string; confirmedPreviewDigest: string; authority: SourceRevertAuthority;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Binding = {
|
|
51
|
+
cwd: string;
|
|
52
|
+
rawSessionId: string;
|
|
53
|
+
guardInstanceId: string;
|
|
54
|
+
facts(): SourceMutationGuardFacts | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function bindingKey(cwd: string, rawSessionId: string): string { return `${fs.realpathSync.native(cwd)}\0${rawSessionId}`; }
|
|
58
|
+
function inside(root: string, candidate: string): boolean {
|
|
59
|
+
const relative = path.relative(root, candidate);
|
|
60
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rejected(request: SourceMutationGuardRequest, reasonCode: string): SourceIndexTransactionResult {
|
|
64
|
+
return { state: "rejected", reasonCode, beforeIndexPreimage: request.expectedIndexPreimage, afterIndexPreimage: null,
|
|
65
|
+
beforeWorkspacePreimage: request.expectedWorkspacePreimage, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false };
|
|
66
|
+
}
|
|
67
|
+
function revertRejected(request: SourceRevertGuardRequest, reasonCode: string): SourceIndexTransactionResult {
|
|
68
|
+
return { state: "rejected", reasonCode, beforeIndexPreimage: request.expectedIndexPreimage, afterIndexPreimage: null,
|
|
69
|
+
beforeWorkspacePreimage: request.expectedWorkspacePreimage, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class PiSourceMutationGuard {
|
|
73
|
+
readonly #bindings = new Map<string, Binding>();
|
|
74
|
+
|
|
75
|
+
bind(input: Binding): () => void {
|
|
76
|
+
const key = bindingKey(input.cwd, input.rawSessionId);
|
|
77
|
+
this.#bindings.set(key, { ...input, cwd: fs.realpathSync.native(input.cwd) });
|
|
78
|
+
return () => {
|
|
79
|
+
const current = this.#bindings.get(key);
|
|
80
|
+
if (current?.guardInstanceId === input.guardInstanceId) this.#bindings.delete(key);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
available(cwd: string, rawSessionId: string): boolean {
|
|
85
|
+
try {
|
|
86
|
+
const binding = this.#bindings.get(bindingKey(cwd, rawSessionId)), facts = binding?.facts();
|
|
87
|
+
return Boolean(facts && facts.taskState === "active" && facts.idle);
|
|
88
|
+
} catch { return false; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async execute(request: SourceMutationGuardRequest): Promise<SourceIndexTransactionResult> {
|
|
92
|
+
let binding: Binding | undefined;
|
|
93
|
+
try { binding = this.#bindings.get(bindingKey(request.cwd, request.rawSessionId)); }
|
|
94
|
+
catch { return rejected(request, "mutation-guard-unavailable"); }
|
|
95
|
+
if (!binding || !this.#validRequest(request)) return rejected(request, binding ? "mutation-guard-request-invalid" : "mutation-guard-unavailable");
|
|
96
|
+
const recheck = (): boolean => {
|
|
97
|
+
try {
|
|
98
|
+
const facts = binding!.facts();
|
|
99
|
+
return Boolean(facts && facts.taskState === "active" && facts.idle
|
|
100
|
+
&& facts.taskId === request.identity.taskId && facts.taskRunId === request.identity.taskRunId
|
|
101
|
+
&& facts.taskRevision === request.expectedTaskRevision && facts.controlRevision === request.expectedControlRevision
|
|
102
|
+
&& this.#pathsAllowed(binding!, facts, request.authority));
|
|
103
|
+
} catch { return false; }
|
|
104
|
+
};
|
|
105
|
+
if (!recheck()) return rejected(request, "mutation-guard-precondition-stale");
|
|
106
|
+
return executeGuardedSourceIndexTransaction({ authority: request.authority,
|
|
107
|
+
expectedIndexPreimage: request.expectedIndexPreimage, expectedWorkspacePreimage: request.expectedWorkspacePreimage,
|
|
108
|
+
selectedHunkRefs: request.selectedHunkRefs, recheck });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async executeRevert(request: SourceRevertGuardRequest): Promise<SourceIndexTransactionResult> {
|
|
112
|
+
let binding: Binding | undefined;
|
|
113
|
+
try { binding = this.#bindings.get(bindingKey(request.cwd, request.rawSessionId)); }
|
|
114
|
+
catch { return revertRejected(request, "mutation-guard-unavailable"); }
|
|
115
|
+
if (!binding || !this.#validRevertRequest(request)) return revertRejected(request, binding ? "mutation-guard-request-invalid" : "mutation-guard-unavailable");
|
|
116
|
+
const recheck = (): boolean => {
|
|
117
|
+
try {
|
|
118
|
+
const facts = binding!.facts(), target = request.authority.target;
|
|
119
|
+
return Boolean(facts && facts.taskState === "active" && facts.idle && Date.now() <= Date.parse(target.expiresAt)
|
|
120
|
+
&& facts.taskId === request.identity.taskId && facts.taskRunId === request.identity.taskRunId
|
|
121
|
+
&& facts.taskRevision === request.expectedTaskRevision && facts.controlRevision === request.expectedControlRevision
|
|
122
|
+
&& this.#pathsAllowed(binding!, facts, { repoRoot: request.authority.repoRoot, repoPaths: [request.authority.repoPath] }));
|
|
123
|
+
} catch { return false; }
|
|
124
|
+
};
|
|
125
|
+
if (!recheck()) return revertRejected(request, "mutation-guard-precondition-stale");
|
|
126
|
+
return executeGuardedSourceWorktreeRevert({ authority: request.authority, expectedIndexPreimage: request.expectedIndexPreimage,
|
|
127
|
+
expectedWorkspacePreimage: request.expectedWorkspacePreimage, recheck });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#validRequest(request: SourceMutationGuardRequest): boolean {
|
|
131
|
+
try { validateSourceMutationTarget(request.authority.target); }
|
|
132
|
+
catch { return false; }
|
|
133
|
+
const target = request.authority.target;
|
|
134
|
+
return REF.test(request.identity.projectRef) && REF.test(request.identity.runtimeInstanceId) && REF.test(request.identity.sessionRef)
|
|
135
|
+
&& REF.test(request.identity.taskId) && REF.test(request.identity.taskRunId)
|
|
136
|
+
&& REVISION.test(request.expectedTaskRevision) && REVISION.test(request.expectedControlRevision)
|
|
137
|
+
&& DIGEST.test(request.expectedIndexPreimage) && WORKSPACE.test(request.expectedWorkspacePreimage)
|
|
138
|
+
&& target.taskRevision === request.expectedTaskRevision && target.indexPreimage === request.expectedIndexPreimage
|
|
139
|
+
&& target.workspacePreimage === request.expectedWorkspacePreimage
|
|
140
|
+
&& Array.isArray(request.selectedHunkRefs) && request.selectedHunkRefs.length <= 128
|
|
141
|
+
&& new Set(request.selectedHunkRefs).size === request.selectedHunkRefs.length
|
|
142
|
+
&& request.selectedHunkRefs.every((hunkRef) => REF.test(hunkRef) && target.hunkRefs.includes(hunkRef))
|
|
143
|
+
&& JSON.stringify(request.selectedHunkRefs) === JSON.stringify(target.hunkRefs.filter((hunkRef) => request.selectedHunkRefs.includes(hunkRef)))
|
|
144
|
+
&& (request.selectedHunkRefs.length === 0 || Boolean(request.authority.patchAuthority && target.status === "M"
|
|
145
|
+
&& target.oldPath === null && request.authority.repoPaths.length === 1))
|
|
146
|
+
&& (request.action === "source.stage" && target.view === "working-tree" && target.effect === "copy-worktree-to-index"
|
|
147
|
+
|| request.action === "source.unstage" && target.view === "staged" && target.effect === "restore-index-from-head");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#validRevertRequest(request: SourceRevertGuardRequest): boolean {
|
|
151
|
+
try { validateSourceRevertTarget(request.authority.target); } catch { return false; }
|
|
152
|
+
const target = request.authority.target;
|
|
153
|
+
return request.action === "source.revert" && REF.test(request.identity.projectRef) && REF.test(request.identity.runtimeInstanceId)
|
|
154
|
+
&& REF.test(request.identity.sessionRef) && REF.test(request.identity.taskId) && REF.test(request.identity.taskRunId)
|
|
155
|
+
&& REVISION.test(request.expectedTaskRevision) && REVISION.test(request.expectedControlRevision)
|
|
156
|
+
&& DIGEST.test(request.expectedIndexPreimage) && WORKSPACE.test(request.expectedWorkspacePreimage)
|
|
157
|
+
&& REF.test(request.previewRef) && DIGEST.test(request.confirmedPreviewDigest)
|
|
158
|
+
&& request.previewRef === target.previewRef && request.confirmedPreviewDigest === target.confirmedPreviewDigest
|
|
159
|
+
&& target.taskRevision === request.expectedTaskRevision && target.indexPreimage === request.expectedIndexPreimage
|
|
160
|
+
&& target.workspacePreimage === request.expectedWorkspacePreimage && target.view === "working-tree"
|
|
161
|
+
&& target.status === "M" && target.oldPath === null && target.effect === "restore-worktree-from-index"
|
|
162
|
+
&& request.authority.repoPath === target.path && request.authority.patchAuthority.hunks.length >= 1
|
|
163
|
+
&& target.hunkRefs.every((hunkRef) => request.authority.patchAuthority.hunks.some((hunk) => hunk.hunkRef === hunkRef));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#pathsAllowed(binding: Binding, facts: SourceMutationGuardFacts, authority: Pick<SourceMutationAuthority, "repoRoot" | "repoPaths">): boolean {
|
|
167
|
+
const projectRoot = binding.cwd;
|
|
168
|
+
let repoRoot: string;
|
|
169
|
+
try { repoRoot = fs.realpathSync.native(authority.repoRoot); }
|
|
170
|
+
catch { return false; }
|
|
171
|
+
if (!authority.repoPaths.length || authority.repoPaths.length > 2) return false;
|
|
172
|
+
return authority.repoPaths.every((repoPath) => {
|
|
173
|
+
if (!repoPath || path.isAbsolute(repoPath) || repoPath.includes("\0")) return false;
|
|
174
|
+
const absolute = path.resolve(repoRoot, repoPath);
|
|
175
|
+
if (!inside(repoRoot, absolute) || !inside(projectRoot, absolute)) return false;
|
|
176
|
+
const relative = path.relative(projectRoot, absolute).split(path.sep).join("/");
|
|
177
|
+
return Boolean(relative && relative !== "." && !facts.isProtectedPath(relative));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const BROKER_KEY = Symbol.for("piagent.webui.source-mutation-guard.v1");
|
|
183
|
+
const root = globalThis as typeof globalThis & { [BROKER_KEY]?: PiSourceMutationGuard };
|
|
184
|
+
export const piSourceMutationGuard = root[BROKER_KEY] ??= new PiSourceMutationGuard();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { collectExactGitPatchAuthority } from "../inspection/diff-projection.ts";
|
|
8
|
+
import { collectGitStatusForPaths, runReadOnlyGit } from "../inspection/git-status-adapter.ts";
|
|
9
|
+
import { localFilterDisableArgs } from "../inspection/git-filter-safety.ts";
|
|
10
|
+
import { collectIndexPreimage, collectSelectedWorkspacePreimage } from "../inspection/source-mutation-projection.ts";
|
|
11
|
+
import type { SourceRevertAuthority } from "../inspection/source-revert-projection.ts";
|
|
12
|
+
import type { SourceIndexTransactionResult } from "./source-index-transaction.ts";
|
|
13
|
+
|
|
14
|
+
function result(value: Omit<SourceIndexTransactionResult, "executor" | "directExecution">): SourceIndexTransactionResult {
|
|
15
|
+
return { ...value, executor: "pi-guard", directExecution: false };
|
|
16
|
+
}
|
|
17
|
+
function environment(): NodeJS.ProcessEnv {
|
|
18
|
+
return { PATH: process.env.PATH, LC_ALL: "C", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
|
|
19
|
+
GIT_OPTIONAL_LOCKS: "0", GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "",
|
|
20
|
+
...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec } : {}) };
|
|
21
|
+
}
|
|
22
|
+
async function runGit(repoRoot: string, args: string[], input: Buffer, timeoutMs: number): Promise<void> {
|
|
23
|
+
const filterArgs = await localFilterDisableArgs(repoRoot, timeoutMs);
|
|
24
|
+
const argv = ["--no-pager", "--literal-pathspecs", "-c", "color.ui=false", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`,
|
|
25
|
+
"-c", "diff.external=", "-c", "submodule.recurse=false", ...filterArgs, "-C", repoRoot, ...args];
|
|
26
|
+
await new Promise<void>((resolve, reject) => {
|
|
27
|
+
const child = spawn("git", argv, { cwd: repoRoot, env: environment(), shell: false, stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
|
|
28
|
+
let stderr = 0, settled = false; child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.length; if (stderr > 64 * 1024) child.kill("SIGKILL"); });
|
|
29
|
+
child.stdin.on("error", () => undefined); child.stdin.end(input);
|
|
30
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); timer.unref?.();
|
|
31
|
+
child.once("error", () => { clearTimeout(timer); if (!settled) { settled = true; reject(new Error("git-revert-start-failed")); } });
|
|
32
|
+
child.once("close", (code) => { clearTimeout(timer); if (settled) return; settled = true;
|
|
33
|
+
if (code === 0 && stderr <= 64 * 1024) resolve(); else reject(new Error("git-revert-failed")); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function gitDirectory(repoRoot: string): Promise<string> {
|
|
37
|
+
const candidate = (await runReadOnlyGit(repoRoot, ["rev-parse", "--absolute-git-dir"], { maxBytes: 16 * 1024 })).toString("utf8").trim();
|
|
38
|
+
const resolved = fs.realpathSync.native(candidate), stat = fs.lstatSync(resolved);
|
|
39
|
+
if (!path.isAbsolute(candidate) || !stat.isDirectory() || stat.isSymbolicLink()) throw new Error("git-directory-invalid");
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
async function restoreWholeFile(authority: SourceRevertAuthority, recheck: () => Promise<boolean>): Promise<void> {
|
|
43
|
+
const bytes = await runReadOnlyGit(authority.repoRoot, ["cat-file", "blob", authority.indexObject], { maxBytes: 16 * 1024 * 1024 });
|
|
44
|
+
const target = path.resolve(authority.repoRoot, authority.repoPath), parent = fs.realpathSync.native(path.dirname(target));
|
|
45
|
+
if (parent !== path.dirname(target) || !target.startsWith(`${authority.repoRoot}${path.sep}`)) throw new Error("revert-target-unsafe");
|
|
46
|
+
const current = fs.lstatSync(target); if (!current.isFile() || current.isSymbolicLink()) throw new Error("revert-target-unsafe");
|
|
47
|
+
const temporary = path.join(parent, `.${path.basename(target)}.piagent-revert.${process.pid}.${randomBytes(8).toString("hex")}`);
|
|
48
|
+
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
49
|
+
try { fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, authority.indexMode === "100755" ? 0o755 : 0o644); }
|
|
50
|
+
finally { fs.closeSync(descriptor); }
|
|
51
|
+
try { if (!await recheck()) throw new Error("revert-preimage-stale"); fs.renameSync(temporary, target); }
|
|
52
|
+
finally { fs.rmSync(temporary, { force: true }); }
|
|
53
|
+
const actual = fs.readFileSync(target); if (!actual.equals(bytes)) throw new Error("revert-postcondition-failed");
|
|
54
|
+
}
|
|
55
|
+
async function restoreSelectedHunk(authority: SourceRevertAuthority, hunkRef: string, timeoutMs: number): Promise<void> {
|
|
56
|
+
const block = authority.patchAuthority.hunks.find((hunk) => hunk.hunkRef === hunkRef);
|
|
57
|
+
if (!block) throw new Error("revert-hunk-stale");
|
|
58
|
+
const patch = Buffer.concat([authority.patchAuthority.header, block.bytes]);
|
|
59
|
+
if (patch.length > 2 * 1024 * 1024) throw new Error("revert-patch-oversized");
|
|
60
|
+
await runGit(authority.repoRoot, ["apply", "--reverse", "--recount", "--whitespace=nowarn", "-"], patch, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function executeGuardedSourceWorktreeRevert(options: { authority: SourceRevertAuthority; expectedIndexPreimage: string;
|
|
64
|
+
expectedWorkspacePreimage: string; recheck(): boolean; timeoutMs?: number }): Promise<SourceIndexTransactionResult> {
|
|
65
|
+
const before = { beforeIndexPreimage: options.expectedIndexPreimage, beforeWorkspacePreimage: options.expectedWorkspacePreimage };
|
|
66
|
+
let lockPath = "", ownsLock = false, committed = false;
|
|
67
|
+
try {
|
|
68
|
+
const gitDir = await gitDirectory(options.authority.repoRoot), lockName = createHash("sha256").update(options.authority.repoPath).digest("hex");
|
|
69
|
+
lockPath = path.join(gitDir, `piagent-webui-worktree-${lockName}.lock`);
|
|
70
|
+
const descriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
71
|
+
fs.closeSync(descriptor); ownsLock = true;
|
|
72
|
+
const lockedIndex = await collectIndexPreimage(options.authority.repoRoot);
|
|
73
|
+
const lockedWorkspace = collectSelectedWorkspacePreimage(options.authority.repoRoot, [options.authority.repoPath]);
|
|
74
|
+
if (!options.recheck() || lockedIndex !== options.expectedIndexPreimage || lockedWorkspace !== options.expectedWorkspacePreimage)
|
|
75
|
+
return result({ state: "rejected", reasonCode: "mutation-preimage-stale", ...before,
|
|
76
|
+
afterIndexPreimage: lockedIndex, afterWorkspacePreimage: lockedWorkspace });
|
|
77
|
+
const finalRecheck = async () => options.recheck()
|
|
78
|
+
&& await collectIndexPreimage(options.authority.repoRoot) === options.expectedIndexPreimage
|
|
79
|
+
&& collectSelectedWorkspacePreimage(options.authority.repoRoot, [options.authority.repoPath]) === options.expectedWorkspacePreimage;
|
|
80
|
+
if (options.authority.target.hunkRefs.length) {
|
|
81
|
+
if (!await finalRecheck()) return result({ state: "rejected", reasonCode: "mutation-preimage-stale", ...before,
|
|
82
|
+
afterIndexPreimage: null, afterWorkspacePreimage: null });
|
|
83
|
+
await restoreSelectedHunk(options.authority, options.authority.target.hunkRefs[0], Math.max(100, Math.min(60_000, options.timeoutMs ?? 5_000)));
|
|
84
|
+
} else await restoreWholeFile(options.authority, finalRecheck);
|
|
85
|
+
committed = true;
|
|
86
|
+
const afterIndexPreimage = await collectIndexPreimage(options.authority.repoRoot);
|
|
87
|
+
const afterWorkspacePreimage = collectSelectedWorkspacePreimage(options.authority.repoRoot, [options.authority.repoPath]);
|
|
88
|
+
if (afterIndexPreimage !== options.expectedIndexPreimage || afterWorkspacePreimage === options.expectedWorkspacePreimage)
|
|
89
|
+
return result({ state: "uncertain", reasonCode: "mutation-postcondition-unknown", ...before, afterIndexPreimage, afterWorkspacePreimage });
|
|
90
|
+
if (options.authority.target.hunkRefs.length) {
|
|
91
|
+
const snapshot = await collectGitStatusForPaths(options.authority.repoRoot, [options.authority.repoPath]);
|
|
92
|
+
const record = snapshot.records.find((item) => item.path.value === options.authority.repoPath);
|
|
93
|
+
if (record) {
|
|
94
|
+
const remaining = await collectExactGitPatchAuthority({ snapshot, record, view: "unstaged", fileRef: options.authority.target.fileRef });
|
|
95
|
+
if (remaining?.hunks.some((hunk) => options.authority.target.hunkRefs.includes(hunk.hunkRef)))
|
|
96
|
+
return result({ state: "uncertain", reasonCode: "mutation-postcondition-unknown", ...before, afterIndexPreimage, afterWorkspacePreimage });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result({ state: "settled", reasonCode: null, ...before, afterIndexPreimage, afterWorkspacePreimage });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const reasonCode = (error as NodeJS.ErrnoException).code === "EEXIST" ? "worktree-revert-locked"
|
|
102
|
+
: committed ? "mutation-postcondition-unknown" : "worktree-revert-failed";
|
|
103
|
+
return result({ state: committed ? "uncertain" : "rejected", reasonCode, ...before, afterIndexPreimage: null, afterWorkspacePreimage: null });
|
|
104
|
+
} finally { if (ownsLock) try { fs.rmSync(lockPath, { force: true }); } catch {} }
|
|
105
|
+
}
|