firedrill-run 2.2.5 → 3.0.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/README.md +176 -22
- package/bin/firedrill.mjs +29 -5
- package/dist/assembly.json +13 -0
- package/dist/cli/runtime/candidate_copy.py +395 -0
- package/dist/cli/runtime/source_snapshot.py +391 -0
- package/dist/cli/src/administration.js +295 -0
- package/dist/cli/src/agent-changes.js +496 -0
- package/dist/cli/src/assessment-requests.js +67 -0
- package/dist/cli/src/auth.js +506 -0
- package/dist/cli/src/billing-commerce-contracts.js +1 -0
- package/dist/cli/src/billing-usage.js +227 -0
- package/dist/cli/src/billing-wallet-contracts.js +1 -0
- package/dist/cli/src/billing.js +170 -0
- package/dist/cli/src/bootstrap-process-lock.js +136 -0
- package/dist/cli/src/browser-links.js +14 -0
- package/dist/cli/src/campaign-controls.js +122 -0
- package/dist/cli/src/campaign-diagnoses.js +118 -0
- package/dist/cli/src/campaign-reports.js +1215 -0
- package/dist/cli/src/campaign-world.js +156 -0
- package/dist/cli/src/candidate-copy.js +376 -0
- package/dist/cli/src/catalog-contracts.js +1 -0
- package/dist/cli/src/ci-access-contracts.js +1 -0
- package/dist/cli/src/ci-configuration-contracts.js +1 -0
- package/dist/cli/src/ci-coverage.js +64 -0
- package/dist/cli/src/ci-delivery-contracts.js +1 -0
- package/dist/cli/src/ci-result-contracts.js +1 -0
- package/dist/cli/src/ci-run-contracts.js +1 -0
- package/dist/cli/src/ci-setup-proposals.js +52 -0
- package/dist/cli/src/company-policies.js +229 -0
- package/dist/cli/src/completion.js +79 -0
- package/dist/cli/src/configuration-intents.js +277 -0
- package/dist/cli/src/configuration.js +369 -0
- package/dist/cli/src/continuous-simulation.js +277 -0
- package/dist/cli/src/coordinated-checkpoints.js +133 -0
- package/dist/cli/src/credential-mode.js +139 -0
- package/dist/cli/src/credentials.js +331 -0
- package/dist/cli/src/credit-format.js +8 -0
- package/dist/cli/src/dataset-contracts.js +1 -0
- package/dist/cli/src/dataset-export.js +154 -0
- package/dist/cli/src/dataset-reads.js +37 -0
- package/dist/cli/src/dependency-preparation.js +239 -0
- package/dist/cli/src/director-tool-setup.js +104 -0
- package/dist/cli/src/driver-review.js +225 -0
- package/dist/cli/src/evaluation-authoring.js +315 -0
- package/dist/cli/src/evidence-bundles.js +367 -0
- package/dist/cli/src/execution-proposals.js +36 -0
- package/dist/cli/src/http.js +302 -0
- package/dist/cli/src/local-container-boundary.js +38 -0
- package/dist/cli/src/local-container-policy.js +201 -0
- package/dist/cli/src/local-execution-setup.js +81 -0
- package/dist/cli/src/local-source-basis.js +73 -0
- package/dist/cli/src/local-source.js +66 -0
- package/dist/cli/src/local-value-store.js +91 -0
- package/dist/cli/src/local-values.js +163 -0
- package/dist/cli/src/login-organizations.js +70 -0
- package/dist/cli/src/main.js +1627 -0
- package/dist/cli/src/mcp-connection.js +109 -0
- package/dist/cli/src/native/bindings.js +137 -0
- package/dist/cli/src/native/canonical.js +370 -0
- package/dist/cli/src/native/cloud-resources.js +942 -0
- package/dist/cli/src/native/companion-channel.js +421 -0
- package/dist/cli/src/native/companion-ipc.js +473 -0
- package/dist/cli/src/native/contracts.js +218 -0
- package/dist/cli/src/native/control-plane.js +1354 -0
- package/dist/cli/src/native/credit-approval.js +15 -0
- package/dist/cli/src/native/dependency-proxy.js +1006 -0
- package/dist/cli/src/native/drill-cleanup.js +115 -0
- package/dist/cli/src/native/drill-runner.js +1562 -0
- package/dist/cli/src/native/drivers/action-trace.js +210 -0
- package/dist/cli/src/native/drivers/function-wrapper.js +152 -0
- package/dist/cli/src/native/drivers/http.js +442 -0
- package/dist/cli/src/native/drivers/index.js +47 -0
- package/dist/cli/src/native/drivers/jsonl-process.js +538 -0
- package/dist/cli/src/native/drivers/playwright.js +600 -0
- package/dist/cli/src/native/drivers/signed-event.js +244 -0
- package/dist/cli/src/native/drivers/types.js +578 -0
- package/dist/cli/src/native/evidence-filter.js +255 -0
- package/dist/cli/src/native/isolated-access.js +241 -0
- package/dist/cli/src/native/library-sharing.js +220 -0
- package/dist/cli/src/native/local-run.js +667 -0
- package/dist/cli/src/native/local-secrets.js +19 -0
- package/dist/cli/src/native/mcp-config.js +123 -0
- package/dist/cli/src/native/metadata-requests.js +120 -0
- package/dist/cli/src/native/observations.js +1159 -0
- package/dist/cli/src/native/owned-groups.js +46 -0
- package/dist/cli/src/native/plan.js +594 -0
- package/dist/cli/src/native/png.js +159 -0
- package/dist/cli/src/native/ports.js +43 -0
- package/dist/cli/src/native/readiness.js +139 -0
- package/dist/cli/src/native/reassessments.js +206 -0
- package/dist/cli/src/native/redaction.js +235 -0
- package/dist/cli/src/native/registration.js +155 -0
- package/dist/cli/src/native/roots.js +110 -0
- package/dist/cli/src/native/run-cleanup.js +199 -0
- package/dist/cli/src/native/scratch.js +117 -0
- package/dist/cli/src/native/screenshots.js +60 -0
- package/dist/cli/src/native/session.js +556 -0
- package/dist/cli/src/native/standalone-proxy.js +580 -0
- package/dist/cli/src/native/standalone-renewals.js +183 -0
- package/dist/cli/src/native/standalone-state.js +193 -0
- package/dist/cli/src/native/standalone-tools.js +187 -0
- package/dist/cli/src/native/supervisor.js +831 -0
- package/dist/cli/src/native/target-auth.js +161 -0
- package/dist/cli/src/native/target-state.js +330 -0
- package/dist/cli/src/native/tool-reuse.js +364 -0
- package/dist/cli/src/native/tool-setups.js +336 -0
- package/dist/cli/src/native/world-reuse.js +288 -0
- package/dist/cli/src/onboarding.js +23 -0
- package/dist/cli/src/priority-requests.js +70 -0
- package/dist/cli/src/priority.js +223 -0
- package/dist/cli/src/prompts.js +184 -0
- package/dist/cli/src/real-operations.js +221 -0
- package/dist/cli/src/recipe-intents.js +58 -0
- package/dist/cli/src/recorded-context-intents.js +59 -0
- package/dist/cli/src/recorded-context.js +248 -0
- package/dist/cli/src/recordings.js +69 -0
- package/dist/cli/src/registration-guide.js +39 -0
- package/dist/cli/src/report-exports.js +213 -0
- package/dist/cli/src/request-input.js +47 -0
- package/dist/cli/src/resource-lifecycle.js +141 -0
- package/dist/cli/src/revision-requests.js +55 -0
- package/dist/cli/src/runner-abandon.js +53 -0
- package/dist/cli/src/runner-campaign-controls.js +262 -0
- package/dist/cli/src/runner-claim-reads.js +36 -0
- package/dist/cli/src/runner-completion.js +65 -0
- package/dist/cli/src/runner-configuration.js +546 -0
- package/dist/cli/src/runner-container.js +794 -0
- package/dist/cli/src/runner-evidence-erasure.js +351 -0
- package/dist/cli/src/runner-evidence-privacy.js +37 -0
- package/dist/cli/src/runner-evidence-retention.js +161 -0
- package/dist/cli/src/runner-evidence.js +326 -0
- package/dist/cli/src/runner-execute.js +92 -0
- package/dist/cli/src/runner-execution-deadline.js +25 -0
- package/dist/cli/src/runner-grants.js +307 -0
- package/dist/cli/src/runner-identity.js +615 -0
- package/dist/cli/src/runner-inputs.js +308 -0
- package/dist/cli/src/runner-jobs.js +49 -0
- package/dist/cli/src/runner-model-transport.js +392 -0
- package/dist/cli/src/runner-ownership.js +332 -0
- package/dist/cli/src/runner-process-lock.js +175 -0
- package/dist/cli/src/runner-runtime.js +396 -0
- package/dist/cli/src/runner-session-channel.js +377 -0
- package/dist/cli/src/runner-trial-completion.js +34 -0
- package/dist/cli/src/runner-trial-journal.js +358 -0
- package/dist/cli/src/runner-trial-live.js +393 -0
- package/dist/cli/src/runner-trial-protocol.js +289 -0
- package/dist/cli/src/runner-trial-sync.js +110 -0
- package/dist/cli/src/runner-trial-transfer.js +107 -0
- package/dist/cli/src/runner-voice.js +40 -0
- package/dist/cli/src/runner-world-claim.js +21 -0
- package/dist/cli/src/runner-world-collector.js +159 -0
- package/dist/cli/src/runner-world-command-evidence.js +487 -0
- package/dist/cli/src/runner-world-commands.js +268 -0
- package/dist/cli/src/runner-world-completion.js +76 -0
- package/dist/cli/src/runner-world-erasure.js +95 -0
- package/dist/cli/src/runner-world-evidence-sync.js +91 -0
- package/dist/cli/src/runner-world-journal.js +510 -0
- package/dist/cli/src/runner-world-native-filters.js +134 -0
- package/dist/cli/src/runner-world-observations.js +50 -0
- package/dist/cli/src/runner-world-session-protocol.js +134 -0
- package/dist/cli/src/saved-records.js +40 -0
- package/dist/cli/src/scenario-authoring.js +212 -0
- package/dist/cli/src/scenario-discovery.js +216 -0
- package/dist/cli/src/scenario-paths.js +68 -0
- package/dist/cli/src/scenario-requests.js +4 -0
- package/dist/cli/src/service-receipts.js +105 -0
- package/dist/cli/src/simple/authoring.js +270 -0
- package/dist/cli/src/simple/credit-approval.js +16 -0
- package/dist/cli/src/simple/help.js +98 -0
- package/dist/cli/src/simple/init.js +96 -0
- package/dist/cli/src/simple/library-sharing.js +414 -0
- package/dist/cli/src/simple/metadata-requests.js +46 -0
- package/dist/cli/src/simple/native-report.js +841 -0
- package/dist/cli/src/simple/native-tools.js +780 -0
- package/dist/cli/src/simple/plan-apply.js +506 -0
- package/dist/cli/src/simple/reassessment.js +323 -0
- package/dist/cli/src/simple/register.js +123 -0
- package/dist/cli/src/simple/run.js +122 -0
- package/dist/cli/src/simple/status.js +49 -0
- package/dist/cli/src/simple/tool-renewal.js +308 -0
- package/dist/cli/src/simple/tool-reuse.js +526 -0
- package/dist/cli/src/simple/tool-start-catalog.js +100 -0
- package/dist/cli/src/simple/tool-state.js +426 -0
- package/dist/cli/src/simple/tools-author.js +222 -0
- package/dist/cli/src/simple/watch.js +28 -0
- package/dist/cli/src/simple/world-reuse.js +586 -0
- package/dist/cli/src/simulation-intents.js +406 -0
- package/dist/cli/src/simulation-run.js +1058 -0
- package/dist/cli/src/source-batch.js +322 -0
- package/dist/cli/src/source-connection.js +451 -0
- package/dist/cli/src/source-runtime.js +346 -0
- package/dist/cli/src/source-selection-intents.js +75 -0
- package/dist/cli/src/source-sets.js +214 -0
- package/dist/cli/src/target-models.js +109 -0
- package/dist/cli/src/tool-catalog.js +224 -0
- package/dist/cli/src/tool-command-contracts.js +1 -0
- package/dist/cli/src/tool-command-export.js +245 -0
- package/dist/cli/src/tool-connection-contracts.js +1 -0
- package/dist/cli/src/tool-connection-reads.js +23 -0
- package/dist/cli/src/tool-contributions.js +58 -0
- package/dist/cli/src/tool-data.js +116 -0
- package/dist/cli/src/tool-datasets.js +176 -0
- package/dist/cli/src/tool-group-contracts.js +1 -0
- package/dist/cli/src/tool-group-reads.js +26 -0
- package/dist/cli/src/tool-group-state.js +147 -0
- package/dist/cli/src/tool-groups.js +311 -0
- package/dist/cli/src/tool-instances.js +186 -0
- package/dist/cli/src/tool-lifecycle-contracts.js +1 -0
- package/dist/cli/src/tool-lifecycle-reads.js +30 -0
- package/dist/cli/src/tool-lifecycle.js +323 -0
- package/dist/cli/src/tool-package-contracts.js +1 -0
- package/dist/cli/src/tool-package-reads.js +41 -0
- package/dist/cli/src/tool-records.js +53 -0
- package/dist/cli/src/tool-runtime.js +96 -0
- package/dist/cli/src/tool-state-contracts.js +1 -0
- package/dist/cli/src/tool-state-reads.js +31 -0
- package/dist/cli/src/tools.js +306 -0
- package/dist/cli/src/trial-actions.js +93 -0
- package/dist/cli/src/trial-checkpoints.js +133 -0
- package/dist/cli/src/trial-checks.js +70 -0
- package/dist/cli/src/trial-state.js +197 -0
- package/dist/cli/src/vault.js +266 -0
- package/dist/cli/src/workspace-registration.js +383 -0
- package/dist/cli/src/workspace-team-contracts.js +1 -0
- package/dist/cli/src/world-access.js +231 -0
- package/dist/cli/src/world-agent.js +4 -0
- package/dist/cli/src/world-checkpoint-scopes.js +15 -0
- package/dist/cli/src/world-contracts.js +6 -0
- package/dist/cli/src/world-creation.js +201 -0
- package/dist/cli/src/world-datasets.js +207 -0
- package/dist/cli/src/world-inspection.js +257 -0
- package/dist/cli/src/world-recipes.js +103 -0
- package/dist/cli/src/world-requests.js +241 -0
- package/dist/cli/src/world-session-checkpoints.js +196 -0
- package/dist/cli/src/world-session-lifecycle.js +207 -0
- package/dist/cli/src/world-session-requests.js +215 -0
- package/dist/cli/src/world-session-state.js +145 -0
- package/dist/cli/src/world-session-voice.js +66 -0
- package/dist/cli/src/world-sessions.js +288 -0
- package/dist/cli/src/world-stream.js +99 -0
- package/dist/cli/src/world-time-report.js +58 -0
- package/dist/contracts/agent-change-contracts.js +75 -0
- package/dist/contracts/agent-execution-contracts.js +38 -0
- package/dist/contracts/application-state-contracts.js +26 -0
- package/dist/contracts/assessment-aggregate-contracts.js +15 -0
- package/dist/contracts/assessment-contracts.js +74 -0
- package/dist/contracts/billing-commerce-contracts.js +107 -0
- package/dist/contracts/billing-wallet-contracts.js +43 -0
- package/dist/contracts/campaign-lifecycle.js +1 -0
- package/dist/contracts/campaign-world-contracts.js +80 -0
- package/dist/contracts/catalog-contracts.js +73 -0
- package/dist/contracts/ci-access-contracts.js +68 -0
- package/dist/contracts/ci-configuration-contracts.js +88 -0
- package/dist/contracts/ci-coverage-contracts.js +65 -0
- package/dist/contracts/ci-coverage-labels.js +25 -0
- package/dist/contracts/ci-delivery-contracts.js +61 -0
- package/dist/contracts/ci-result-contracts.js +103 -0
- package/dist/contracts/ci-run-contracts.js +51 -0
- package/dist/contracts/ci-setup-contracts.js +88 -0
- package/dist/contracts/clip-contracts.js +32 -0
- package/dist/contracts/comparison-contracts.js +53 -0
- package/dist/contracts/composite-contracts.js +34 -0
- package/dist/contracts/context-contracts.js +76 -0
- package/dist/contracts/continuation-report-contracts.js +1 -0
- package/dist/contracts/continuity-requirements.js +8 -0
- package/dist/contracts/coordinated-checkpoint-contracts.js +34 -0
- package/dist/contracts/dataset-contracts.js +88 -0
- package/dist/contracts/dependency-decision-contracts.js +36 -0
- package/dist/contracts/dependency-protocol-contracts.js +43 -0
- package/dist/contracts/director-execution.js +51 -0
- package/dist/contracts/driver-review-contracts.js +99 -0
- package/dist/contracts/evidence-contracts.js +65 -0
- package/dist/contracts/evidence-coverage-contracts.js +10 -0
- package/dist/contracts/export-contracts.js +44 -0
- package/dist/contracts/github-directory-contracts.js +96 -0
- package/dist/contracts/insight-contracts.js +11 -0
- package/dist/contracts/inspection-contracts.js +1 -0
- package/dist/contracts/local-claim-contracts.js +39 -0
- package/dist/contracts/local-configuration-contracts.js +7 -0
- package/dist/contracts/local-evidence-contracts.js +32 -0
- package/dist/contracts/local-grant-contracts.js +83 -0
- package/dist/contracts/local-input-contracts.js +19 -0
- package/dist/contracts/local-runner-contracts.js +42 -0
- package/dist/contracts/local-trial-live-contracts.js +16 -0
- package/dist/contracts/local-value-source-contracts.js +92 -0
- package/dist/contracts/local-world-native-contracts.js +8 -0
- package/dist/contracts/mcp-contracts.js +42 -0
- package/dist/contracts/media-contracts.js +28 -0
- package/dist/contracts/native-check-contracts.js +31 -0
- package/dist/contracts/native-identity-contracts.js +52 -0
- package/dist/contracts/onboarding-contracts.js +32 -0
- package/dist/contracts/performance-contracts.js +85 -0
- package/dist/contracts/planning-contracts.js +81 -0
- package/dist/contracts/real-operation-contracts.js +30 -0
- package/dist/contracts/recommendation-contracts.js +54 -0
- package/dist/contracts/report-contracts.js +1 -0
- package/dist/contracts/resource-lifecycle-contracts.js +57 -0
- package/dist/contracts/revision-contracts.js +12 -0
- package/dist/contracts/revision-request-contracts.js +39 -0
- package/dist/contracts/saved-record-contracts.js +76 -0
- package/dist/contracts/scenario-draft-comparison.js +91 -0
- package/dist/contracts/scenario-world-contracts.js +14 -0
- package/dist/contracts/service-contracts.js +1 -0
- package/dist/contracts/session-command-contracts.js +56 -0
- package/dist/contracts/session-contracts.js +65 -0
- package/dist/contracts/session-state-contracts.js +67 -0
- package/dist/contracts/session-voice-contracts.js +45 -0
- package/dist/contracts/source-set-contracts.js +26 -0
- package/dist/contracts/source-upload-contracts.js +22 -0
- package/dist/contracts/tool-command-contracts.js +57 -0
- package/dist/contracts/tool-connection-contracts.js +87 -0
- package/dist/contracts/tool-contribution-contracts.js +35 -0
- package/dist/contracts/tool-group-contracts.js +53 -0
- package/dist/contracts/tool-group-state-contracts.js +63 -0
- package/dist/contracts/tool-lifecycle-contracts.js +94 -0
- package/dist/contracts/tool-package-contracts.js +91 -0
- package/dist/contracts/tool-protocol-contracts.js +71 -0
- package/dist/contracts/tool-runtime-billing-contracts.js +28 -0
- package/dist/contracts/tool-setup-proposal.js +59 -0
- package/dist/contracts/tool-state-contracts.js +79 -0
- package/dist/contracts/trial-state-contracts.js +30 -0
- package/dist/contracts/typescript/agent-change.js +1 -0
- package/dist/contracts/typescript/assessment-aggregate.js +1 -0
- package/dist/contracts/typescript/assessment.js +1 -0
- package/dist/contracts/typescript/billing-commerce.js +1 -0
- package/dist/contracts/typescript/campaigns.js +1 -0
- package/dist/contracts/typescript/catalog.js +1 -0
- package/dist/contracts/typescript/ci-access.js +1 -0
- package/dist/contracts/typescript/ci-configuration.js +1 -0
- package/dist/contracts/typescript/ci-delivery.js +1 -0
- package/dist/contracts/typescript/ci-result.js +1 -0
- package/dist/contracts/typescript/ci-runs.js +1 -0
- package/dist/contracts/typescript/clips.js +1 -0
- package/dist/contracts/typescript/comparison.js +1 -0
- package/dist/contracts/typescript/composite.js +1 -0
- package/dist/contracts/typescript/connections.js +1 -0
- package/dist/contracts/typescript/context.js +1 -0
- package/dist/contracts/typescript/datasets.js +1 -0
- package/dist/contracts/typescript/evidence-coverage.js +1 -0
- package/dist/contracts/typescript/export.js +1 -0
- package/dist/contracts/typescript/github-directory.js +1 -0
- package/dist/contracts/typescript/insight.js +1 -0
- package/dist/contracts/typescript/inspection.js +1 -0
- package/dist/contracts/typescript/intake.js +1 -0
- package/dist/contracts/typescript/local-claim.js +1 -0
- package/dist/contracts/typescript/local-configuration.js +1 -0
- package/dist/contracts/typescript/local-evidence.js +1 -0
- package/dist/contracts/typescript/local-grant.js +1 -0
- package/dist/contracts/typescript/local-input.js +1 -0
- package/dist/contracts/typescript/local-runner.js +1 -0
- package/dist/contracts/typescript/local-trial-live.js +1 -0
- package/dist/contracts/typescript/local-value-source.js +1 -0
- package/dist/contracts/typescript/local-world-native.js +1 -0
- package/dist/contracts/typescript/mcp.js +1 -0
- package/dist/contracts/typescript/media.js +1 -0
- package/dist/contracts/typescript/native-check.js +1 -0
- package/dist/contracts/typescript/native-identity.js +1 -0
- package/dist/contracts/typescript/onboarding.js +1 -0
- package/dist/contracts/typescript/performance.js +1 -0
- package/dist/contracts/typescript/planning.js +1 -0
- package/dist/contracts/typescript/preparation.js +1 -0
- package/dist/contracts/typescript/real-operation.js +1 -0
- package/dist/contracts/typescript/recommendation.js +1 -0
- package/dist/contracts/typescript/reports.js +1 -0
- package/dist/contracts/typescript/resource-lifecycle.js +1 -0
- package/dist/contracts/typescript/saved-record.js +1 -0
- package/dist/contracts/typescript/scenarios.js +1 -0
- package/dist/contracts/typescript/services.js +1 -0
- package/dist/contracts/typescript/session-command.js +1 -0
- package/dist/contracts/typescript/session-state.js +1 -0
- package/dist/contracts/typescript/session-voice.js +1 -0
- package/dist/contracts/typescript/session.js +1 -0
- package/dist/contracts/typescript/tool-command.js +1 -0
- package/dist/contracts/typescript/tool-connection.js +1 -0
- package/dist/contracts/typescript/tool-contribution.js +1 -0
- package/dist/contracts/typescript/tool-groups.js +1 -0
- package/dist/contracts/typescript/tool-lifecycle.js +1 -0
- package/dist/contracts/typescript/tool-package.js +1 -0
- package/dist/contracts/typescript/tool-state.js +1 -0
- package/dist/contracts/typescript/trial-state.js +1 -0
- package/dist/contracts/typescript/vault.js +1 -0
- package/dist/contracts/typescript/workspace-team.js +1 -0
- package/dist/contracts/typescript/world-stream.js +1 -0
- package/dist/contracts/workspace-team-contracts.js +55 -0
- package/dist/contracts/world-command.js +65 -0
- package/dist/contracts/world-lifetime-contracts.js +74 -0
- package/dist/contracts/world-recipe-contracts.js +27 -0
- package/dist/contracts/world-stream-contracts.js +27 -0
- package/dist/package.json +1 -0
- package/package.json +16 -16
- package/LICENSE.md +0 -23
- package/PACKAGING.json +0 -50
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-drill loopback dependency proxy (Plan20 FIR-490, root
|
|
3
|
+
* `native-dependency-access.v1`).
|
|
4
|
+
*
|
|
5
|
+
* Root issues a dependency token only while the drill is ready or executing,
|
|
6
|
+
* for at most 30 seconds, and it must never reach env, files, evidence, logs or
|
|
7
|
+
* the target. So the target is given stable loopback URLs before it starts, and
|
|
8
|
+
* this companion-owned proxy fetches tokens lazily, keeps them in memory and
|
|
9
|
+
* forwards each call to root's tool gateway:
|
|
10
|
+
* - `POST /{cap}/{alias}/v1/invoke`: the Firedrill operation protocol
|
|
11
|
+
* `{service_key?, operation, arguments, idempotency_key?}`, limited to the
|
|
12
|
+
* operations the descriptor declares for that dependency (root's token grants
|
|
13
|
+
* the wider world set); an operation's declared idempotency argument carries
|
|
14
|
+
* the same key as `idempotency_key`, exactly as over MCP;
|
|
15
|
+
* - `POST /{cap}/{alias}/mcp`: stateless MCP. `initialize`, `ping` and
|
|
16
|
+
* `notifications/initialized` are answered locally with root's exact
|
|
17
|
+
* constants, so a target can connect its MCP client before the drill is
|
|
18
|
+
* admitted; `tools/list` and `tools/call` are forwarded with the headers root
|
|
19
|
+
* requires, and every call carries an idempotency key in `_meta` (a generic
|
|
20
|
+
* MCP client cannot add one; root ignores it for reads).
|
|
21
|
+
*
|
|
22
|
+
* Guards: loopback only, exact Host, no Origin (browsers and DNS rebinding are
|
|
23
|
+
* refused), a random per-drill path capability compared in constant time, and
|
|
24
|
+
* root's 1 MiB body limit. A call is retried at most once, with a fresh token and
|
|
25
|
+
* the same idempotency key, so a committed write replays from root's journal and
|
|
26
|
+
* never happens twice. Upstream requests carry exactly one Authorization header,
|
|
27
|
+
* a Content-Length and no Origin, and never follow redirects. The proxy closes
|
|
28
|
+
* before the drill's close is recorded; tokens die with it.
|
|
29
|
+
*
|
|
30
|
+
* Isolated custom tools (root `runtime_support/native_isolated_broker.py`): the
|
|
31
|
+
* same access shape, but `invoke_url`/`mcp_url` may name a separate broker origin
|
|
32
|
+
* that tags each answer with `X-Firedrill-Outcome`. Only `observed` is a tool
|
|
33
|
+
* result. `pending` (HTTP 202, or JSON-RPC -32001 from `tools/call`) is waited on
|
|
34
|
+
* by reading the original operation at root's `recovery_path` on the same origin
|
|
35
|
+
* with a current token, never by resubmitting. `unknown` (409), `not_dispatched`
|
|
36
|
+
* (422) and a wait that ends while still pending reach the target as errors that
|
|
37
|
+
* carry the request id and a loopback `GET /{cap}/{alias}/v1/requests/{id}`
|
|
38
|
+
* recovery route, so a pending acceptance can never become the agent's answer.
|
|
39
|
+
*/
|
|
40
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
41
|
+
import { createServer } from 'node:http';
|
|
42
|
+
import { request as httpsRequest } from 'node:https';
|
|
43
|
+
import { performance } from 'node:perf_hooks';
|
|
44
|
+
import { CliError } from '../http.js';
|
|
45
|
+
import { SecretValue } from './contracts.js';
|
|
46
|
+
import { filterEvidenceValue } from './evidence-filter.js';
|
|
47
|
+
import { errorCode, isFenced, isRevocation, isTransient, sleep } from './control-plane.js';
|
|
48
|
+
/** Root's gateway request body limit. */
|
|
49
|
+
export const BODY_MAX_BYTES = 1_048_576;
|
|
50
|
+
/** Root's tool response limit (16 MiB) plus framing. */
|
|
51
|
+
const RESPONSE_MAX_BYTES = 16 * 1_048_576 + 65_536;
|
|
52
|
+
const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
53
|
+
/** Root's constant `initialize` result (the gateway does no version negotiation). */
|
|
54
|
+
const MCP_INITIALIZE = { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'firedrill-tool', version: '1' } };
|
|
55
|
+
const TOKEN_MARGIN_MS = 5_000;
|
|
56
|
+
/** How long one call waits for root to issue a token (the drill may not be admitted yet). */
|
|
57
|
+
const TOKEN_WAIT_MS = 30_000;
|
|
58
|
+
/** Root's gateway socket timeout. */
|
|
59
|
+
const UPSTREAM_TIMEOUT_MS = 30_000;
|
|
60
|
+
/** The only tool headers root's gateway passes through. */
|
|
61
|
+
const PASSED_HEADERS = ['retry-after', 'etag', 'idempotent-replayed'];
|
|
62
|
+
const INVOKE_KEYS = new Set(['service_key', 'operation', 'arguments', 'idempotency_key']);
|
|
63
|
+
const RPC_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']);
|
|
64
|
+
/** The isolated tool broker's outcome header (root's pooled gateway sends none). */
|
|
65
|
+
const OUTCOME = 'x-firedrill-outcome';
|
|
66
|
+
/** How long one call keeps reading an original operation the broker accepted without a result. */
|
|
67
|
+
const PENDING_WAIT_MS = 45_000;
|
|
68
|
+
/** The broker's JSON-RPC error for a `tools/call` without a confirmed result. */
|
|
69
|
+
const NO_CONFIRMED_RESULT = -32001;
|
|
70
|
+
/** Original operations remembered per dependency for the loopback recovery route. */
|
|
71
|
+
const ORIGINALS_MAX = 10_000;
|
|
72
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
73
|
+
const UNSETTLED_TEXT = {
|
|
74
|
+
pending: 'The simulated tool accepted this operation but has no result yet. Read recovery_path, or resend it with the same idempotency key. Never resend it under a new key.',
|
|
75
|
+
unknown: 'The simulated tool cannot confirm whether this operation took effect. Read recovery_path before acting again. Never resend it under a new key.',
|
|
76
|
+
not_dispatched: 'The simulated tool did not run this operation.',
|
|
77
|
+
};
|
|
78
|
+
/** A call that may have reached the tool without an answer coming back. */
|
|
79
|
+
const UNANSWERED_TEXT = 'The simulated dependency did not answer this call, so it may or may not have taken effect. Resend it with the same idempotency key. Never resend it under a new key.';
|
|
80
|
+
/** The broker finished the operation but its MCP-shaped result could not be read back. */
|
|
81
|
+
const UNDELIVERED_TEXT = 'The simulated tool finished this operation, but its result could not be delivered. Resend the same call with the same idempotency key to read it. Never resend it under a new key.';
|
|
82
|
+
export const DEPENDENCY_ERRORS = {
|
|
83
|
+
/** Root's binding, service key or gateway URLs changed between two tokens of one drill. */
|
|
84
|
+
changed: 'native_dependency_changed',
|
|
85
|
+
/** Root's tool gateway is not configured for this deployment (permanent for the run). */
|
|
86
|
+
unavailable: 'native_dependency_unavailable',
|
|
87
|
+
};
|
|
88
|
+
const captures = new WeakMap();
|
|
89
|
+
function captured(response, status, value, format) {
|
|
90
|
+
const callback = captures.get(response);
|
|
91
|
+
captures.delete(response);
|
|
92
|
+
if (!callback)
|
|
93
|
+
return;
|
|
94
|
+
let done = false;
|
|
95
|
+
const emit = (finished) => {
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
done = true;
|
|
99
|
+
try {
|
|
100
|
+
callback(finished ? status : null, finished ? value : null, finished ? format : 'unavailable');
|
|
101
|
+
}
|
|
102
|
+
catch { /* Evidence never changes the delivered result. */ }
|
|
103
|
+
};
|
|
104
|
+
if (response.writableFinished)
|
|
105
|
+
emit(true);
|
|
106
|
+
else if (response.destroyed)
|
|
107
|
+
emit(false);
|
|
108
|
+
else {
|
|
109
|
+
response.once('finish', () => emit(true));
|
|
110
|
+
response.once('close', () => emit(response.writableFinished));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function scrubExchange(value, secrets) {
|
|
114
|
+
let encoded = JSON.stringify(value), changed = false;
|
|
115
|
+
if (encoded === undefined || Buffer.byteLength(encoded) > 131072)
|
|
116
|
+
throw new Error('capture_size');
|
|
117
|
+
for (const secret of secrets.filter(Boolean)) {
|
|
118
|
+
const safe = encoded.split(JSON.stringify(secret).slice(1, -1)).join('[redacted]');
|
|
119
|
+
changed ||= safe !== encoded;
|
|
120
|
+
encoded = safe;
|
|
121
|
+
}
|
|
122
|
+
const safe = encoded.replace(/https?:\/\/[^"\\\s<>]+/gi, '[private link]');
|
|
123
|
+
changed ||= safe !== encoded;
|
|
124
|
+
const clean = JSON.parse(safe, (name, item) => {
|
|
125
|
+
if (['_meta', 'headers', 'cookies', 'authorization', 'idempotency_key', 'firedrill/idempotency-key', 'recovery_path'].includes(name)) {
|
|
126
|
+
changed = true;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return item;
|
|
130
|
+
});
|
|
131
|
+
return { value: clean, changed };
|
|
132
|
+
}
|
|
133
|
+
export class DependencyProxy {
|
|
134
|
+
#options;
|
|
135
|
+
#server;
|
|
136
|
+
#cap;
|
|
137
|
+
#operations;
|
|
138
|
+
#tokens = new Map();
|
|
139
|
+
#fetching = new Map();
|
|
140
|
+
#pinned = new Map();
|
|
141
|
+
#originals = new Map();
|
|
142
|
+
#captureContexts = new WeakMap();
|
|
143
|
+
#retainedCaptureBytes = 0;
|
|
144
|
+
/** Per dependency: each tool's declared idempotency argument (null: none), from root's tools/list metadata. */
|
|
145
|
+
#arguments = new Map();
|
|
146
|
+
/** Dependencies whose whole tool list this proxy has read. */
|
|
147
|
+
#discovered = new Set();
|
|
148
|
+
#discovering = new Map();
|
|
149
|
+
#controller = new AbortController();
|
|
150
|
+
#port = 0;
|
|
151
|
+
#closed = false;
|
|
152
|
+
/** The path capability, redacted wherever drill secrets are. */
|
|
153
|
+
secret;
|
|
154
|
+
constructor(options) {
|
|
155
|
+
this.#options = options;
|
|
156
|
+
this.#cap = randomBytes(32).toString('base64url');
|
|
157
|
+
this.secret = new SecretValue(this.#cap, 'dependency-proxy');
|
|
158
|
+
// Only synthetic dependencies are served: root issues tokens for simulated tools only.
|
|
159
|
+
this.#operations = new Map(options.descriptor.dependencies.filter((dependency) => dependency.mode === 'synthetic')
|
|
160
|
+
.map((dependency) => [dependency.alias, new Set(dependency.operations)]));
|
|
161
|
+
this.#server = createServer({ requestTimeout: UPSTREAM_TIMEOUT_MS * 3, headersTimeout: 10_000 }, (request, response) => { void this.#handle(request, response); });
|
|
162
|
+
this.#server.on('clientError', (_error, socket) => socket.destroy());
|
|
163
|
+
}
|
|
164
|
+
/** Start the proxy on an ephemeral loopback port. */
|
|
165
|
+
static async start(options) {
|
|
166
|
+
const proxy = new DependencyProxy(options);
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
proxy.#server.once('error', reject);
|
|
169
|
+
proxy.#server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => { proxy.#server.off('error', reject); resolve(); });
|
|
170
|
+
});
|
|
171
|
+
proxy.#port = proxy.#server.address().port;
|
|
172
|
+
if (options.signal.aborted)
|
|
173
|
+
await proxy.close();
|
|
174
|
+
else
|
|
175
|
+
options.signal.addEventListener('abort', () => { void proxy.close(); }, { once: true });
|
|
176
|
+
return proxy;
|
|
177
|
+
}
|
|
178
|
+
/** True when the descriptor declares this synthetic dependency. */
|
|
179
|
+
serves(alias) { return this.#operations.has(alias); }
|
|
180
|
+
/** The loopback URL the target uses for one dependency (it carries the path capability: treat it as secret). */
|
|
181
|
+
url(alias, kind) {
|
|
182
|
+
if (!this.#operations.has(alias))
|
|
183
|
+
throw new CliError('native_dependency_invalid', `Dependency "${alias}" is not a simulated dependency of this target.`);
|
|
184
|
+
return new SecretValue(`http://127.0.0.1:${this.#port}/${this.#cap}/${alias}${kind === 'invoke' ? '/v1/invoke' : '/mcp'}`, `dependency-url:${alias}`);
|
|
185
|
+
}
|
|
186
|
+
/** Stop accepting calls, abort in-flight ones and forget every token. Safe to call more than once. */
|
|
187
|
+
async close() {
|
|
188
|
+
if (this.#closed)
|
|
189
|
+
return;
|
|
190
|
+
this.#closed = true;
|
|
191
|
+
this.#controller.abort();
|
|
192
|
+
this.#tokens.clear();
|
|
193
|
+
await new Promise((resolve) => { this.#server.close(() => resolve()); this.#server.closeAllConnections(); });
|
|
194
|
+
}
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
// Inbound (the target).
|
|
197
|
+
async #handle(request, response) {
|
|
198
|
+
// Only callers holding this drill's loopback capability can affect its
|
|
199
|
+
// evidence accounting. Unauthenticated probes remain ordinary refusals.
|
|
200
|
+
let captureEligible = false;
|
|
201
|
+
try {
|
|
202
|
+
if (this.#closed)
|
|
203
|
+
return refuse(response, 503, 'dependency_proxy_closed');
|
|
204
|
+
if (request.headers.origin !== undefined)
|
|
205
|
+
return refuse(response, 403, 'origin_denied');
|
|
206
|
+
if (request.headers.host !== `127.0.0.1:${this.#port}`)
|
|
207
|
+
return refuse(response, 403, 'host_denied');
|
|
208
|
+
const match = /^\/([A-Za-z0-9_-]{43})\/([a-z][a-z0-9_-]{0,63})\/(v1\/invoke|mcp|v1\/requests\/([0-9a-f-]{36}))$/.exec(request.url ?? '');
|
|
209
|
+
if (!match || !sameCap(match[1], this.#cap) || !this.#operations.has(match[2]))
|
|
210
|
+
return refuse(response, 404, 'not_found');
|
|
211
|
+
if (match[4] !== undefined) {
|
|
212
|
+
if (request.method !== 'GET')
|
|
213
|
+
return refuse(response, 405, 'method_not_supported');
|
|
214
|
+
request.resume();
|
|
215
|
+
return await this.#recover(match[2], match[4], response);
|
|
216
|
+
}
|
|
217
|
+
captureEligible = true;
|
|
218
|
+
if (request.method !== 'POST')
|
|
219
|
+
return refuse(response, 405, 'method_not_supported');
|
|
220
|
+
const declared = Number(request.headers['content-length']);
|
|
221
|
+
if (Number.isFinite(declared) && declared > BODY_MAX_BYTES) {
|
|
222
|
+
this.#captureUnavailable();
|
|
223
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
224
|
+
}
|
|
225
|
+
const body = await readBody(request);
|
|
226
|
+
if (body === null) {
|
|
227
|
+
this.#captureUnavailable();
|
|
228
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
229
|
+
}
|
|
230
|
+
let message;
|
|
231
|
+
try {
|
|
232
|
+
message = JSON.parse(body.toString('utf8'));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
this.#captureUnavailable();
|
|
236
|
+
return refuse(response, 400, 'request_invalid');
|
|
237
|
+
}
|
|
238
|
+
if (match[3] === 'mcp')
|
|
239
|
+
captureEligible = record(message)?.method === 'tools/call';
|
|
240
|
+
if (match[3] === 'mcp')
|
|
241
|
+
await this.#mcp(match[2], message, response);
|
|
242
|
+
else
|
|
243
|
+
await this.#invoke(match[2], message, response);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
if (captureEligible && !this.#captureContexts.has(response))
|
|
247
|
+
this.#captureUnavailable();
|
|
248
|
+
if (!response.headersSent)
|
|
249
|
+
refuse(response, 502, 'dependency_unavailable');
|
|
250
|
+
else
|
|
251
|
+
response.destroy();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async #invoke(alias, message, response) {
|
|
255
|
+
const call = record(message);
|
|
256
|
+
if (!call || !Object.keys(call).every((key) => INVOKE_KEYS.has(key)) || typeof call.operation !== 'string'
|
|
257
|
+
|| !record(call.arguments) || (call.service_key !== undefined && typeof call.service_key !== 'string')
|
|
258
|
+
|| (call.idempotency_key !== undefined && !validKey(call.idempotency_key))) {
|
|
259
|
+
this.#captureUnavailable();
|
|
260
|
+
return refuse(response, 400, 'request_invalid');
|
|
261
|
+
}
|
|
262
|
+
if (!this.#operations.get(alias)?.has(call.operation)) {
|
|
263
|
+
this.#captureUnavailable();
|
|
264
|
+
return refuse(response, 403, 'operation_not_declared');
|
|
265
|
+
}
|
|
266
|
+
// Root refuses a write whose declared idempotency argument and idempotency_key differ, so both carry one key, as over MCP.
|
|
267
|
+
const argument = await this.#idempotencyArgument(alias, call.operation);
|
|
268
|
+
if (argument === undefined) {
|
|
269
|
+
this.#captureUnavailable();
|
|
270
|
+
response.setHeader('retry-after', '1');
|
|
271
|
+
return json(response, 503, { error: 'dependency_not_ready',
|
|
272
|
+
message: 'The simulated dependency\'s tool list could not be read, so nothing was sent. Try again.' });
|
|
273
|
+
}
|
|
274
|
+
const keyed = keyedCall(call.arguments, typeof call.idempotency_key === 'string' ? { [IDEMPOTENCY_META]: call.idempotency_key } : {}, argument);
|
|
275
|
+
if (!keyed) {
|
|
276
|
+
this.#captureUnavailable();
|
|
277
|
+
return json(response, 400, { error: 'idempotency_key_conflict',
|
|
278
|
+
message: `The ${argument} argument and idempotency_key name different keys, so nothing was sent. Use one key.` });
|
|
279
|
+
}
|
|
280
|
+
// One key per inbound call, reused by the retry: a committed write replays instead of repeating.
|
|
281
|
+
const key = keyed.key;
|
|
282
|
+
this.#beginCapture(alias, call.operation, keyed.arguments, argument, 'http', key, response);
|
|
283
|
+
let upstream = await this.#forward(alias, (access) => post(access.invoke_url, access.token, false, Buffer.from(JSON.stringify({
|
|
284
|
+
service_key: access.service_key, operation: call.operation, arguments: keyed.arguments, idempotency_key: key,
|
|
285
|
+
}), 'utf8'), this.#controller.signal), call.service_key);
|
|
286
|
+
if (upstream === 'service_key_mismatch')
|
|
287
|
+
return refuse(response, 400, 'service_key_mismatch');
|
|
288
|
+
if (typeof upstream === 'string') {
|
|
289
|
+
// The call may have reached the tool before the answer was lost: the same key replays it, a new key could repeat it.
|
|
290
|
+
if (upstream === 'dependency_not_ready')
|
|
291
|
+
response.setHeader('retry-after', '1');
|
|
292
|
+
return json(response, upstream === 'dependency_not_ready' ? 503 : 502, { error: upstream, idempotency_key: key, message: UNANSWERED_TEXT });
|
|
293
|
+
}
|
|
294
|
+
if (upstream.status === 409 && gatewayCode(upstream.body) === 'tool_request_unavailable') {
|
|
295
|
+
// The gateway gives this one answer to every failure, including one after the operation was accepted.
|
|
296
|
+
return json(response, 409, { error: 'dependency_request_unavailable', idempotency_key: key, message: UNANSWERED_TEXT });
|
|
297
|
+
}
|
|
298
|
+
if (header(upstream.headers, OUTCOME) === 'pending') {
|
|
299
|
+
// Accepted, not answered: wait on the original operation; never submit it again.
|
|
300
|
+
const original = this.#original(alias, 'invoke', parsed(upstream.body), key, this.#captureContexts.get(response));
|
|
301
|
+
if (!original)
|
|
302
|
+
return json(response, 502, { error: 'dependency_unavailable', idempotency_key: key, message: UNANSWERED_TEXT });
|
|
303
|
+
const settled = await this.#settle(alias, original, Number(header(upstream.headers, 'retry-after')));
|
|
304
|
+
if (typeof settled === 'string')
|
|
305
|
+
return this.#unsettled(response, alias, 'pending', original);
|
|
306
|
+
upstream = settled;
|
|
307
|
+
}
|
|
308
|
+
return this.#relay(alias, upstream, response, key);
|
|
309
|
+
}
|
|
310
|
+
/** Loopback recovery: read one original operation this dependency's broker accepted earlier in the drill. */
|
|
311
|
+
async #recover(alias, requestId, response) {
|
|
312
|
+
const original = this.#originals.get(alias)?.get(requestId);
|
|
313
|
+
if (!original)
|
|
314
|
+
return refuse(response, 404, 'not_found');
|
|
315
|
+
if (original.capture)
|
|
316
|
+
this.#attachCapture({ ...original.capture, protocol: 'http' }, response);
|
|
317
|
+
else
|
|
318
|
+
this.#captureUnavailable();
|
|
319
|
+
const upstream = await this.#read(alias, original);
|
|
320
|
+
if (typeof upstream === 'string')
|
|
321
|
+
return refuse(response, upstream === 'dependency_not_ready' ? 503 : 502, upstream, 1);
|
|
322
|
+
return this.#relay(alias, upstream, response, original.key);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Relay an operation answer. Without an outcome header (root's pooled gateway)
|
|
326
|
+
* or with `observed`, it is the tool's answer. Any other outcome is relayed as
|
|
327
|
+
* an error with loopback recovery, and an unrecognized one as unavailable. A
|
|
328
|
+
* scenario outage the broker refused before dispatch is relayed as it is, as
|
|
329
|
+
* the pooled gateway answers the same outage.
|
|
330
|
+
*/
|
|
331
|
+
#relay(alias, upstream, response, key) {
|
|
332
|
+
const outcome = header(upstream.headers, OUTCOME);
|
|
333
|
+
if (outcome === 'pending' || outcome === 'unknown' || outcome === 'not_dispatched') {
|
|
334
|
+
const original = this.#original(alias, 'invoke', parsed(upstream.body), key, this.#captureContexts.get(response));
|
|
335
|
+
if (original)
|
|
336
|
+
return this.#unsettled(response, alias, outcome, original);
|
|
337
|
+
// The broker's outage answer has no request id: nothing was run, so it is not an unanswered call.
|
|
338
|
+
const outage = outcome === 'not_dispatched' && upstream.status === 503 && gatewayCode(upstream.body) === 'dependency_unavailable';
|
|
339
|
+
if (!outage)
|
|
340
|
+
return refuse(response, 502, 'dependency_unavailable');
|
|
341
|
+
}
|
|
342
|
+
else if (outcome !== null && outcome !== 'observed')
|
|
343
|
+
return refuse(response, 502, 'dependency_unavailable');
|
|
344
|
+
const headers = {
|
|
345
|
+
'content-type': header(upstream.headers, 'content-type') ?? 'application/octet-stream',
|
|
346
|
+
'content-length': String(upstream.body.length), 'cache-control': 'no-store',
|
|
347
|
+
};
|
|
348
|
+
for (const name of PASSED_HEADERS) {
|
|
349
|
+
const value = header(upstream.headers, name);
|
|
350
|
+
if (value !== null)
|
|
351
|
+
headers[name] = value;
|
|
352
|
+
}
|
|
353
|
+
if (outcome === 'observed')
|
|
354
|
+
headers[OUTCOME] = 'observed';
|
|
355
|
+
response.writeHead(upstream.status, headers);
|
|
356
|
+
const contentType = headers['content-type'].split(';')[0].trim().toLowerCase();
|
|
357
|
+
if (upstream.body.length > 131072)
|
|
358
|
+
captured(response, upstream.status, null, 'unavailable');
|
|
359
|
+
else if (contentType === 'application/json' || contentType.endsWith('+json')) {
|
|
360
|
+
try {
|
|
361
|
+
captured(response, upstream.status, JSON.parse(upstream.body.toString('utf8')), 'json');
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
captured(response, upstream.status, null, 'unavailable');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (contentType.startsWith('text/'))
|
|
368
|
+
captured(response, upstream.status, upstream.body.toString('utf8'), 'text');
|
|
369
|
+
else
|
|
370
|
+
captured(response, upstream.status, null, 'unavailable');
|
|
371
|
+
response.end(upstream.body);
|
|
372
|
+
}
|
|
373
|
+
/** An operation without a confirmed result, as an error the target cannot mistake for an answer. */
|
|
374
|
+
#unsettled(response, alias, outcome, original) {
|
|
375
|
+
response.setHeader(OUTCOME, outcome);
|
|
376
|
+
if (outcome === 'pending')
|
|
377
|
+
response.setHeader('retry-after', '1');
|
|
378
|
+
json(response, outcome === 'not_dispatched' ? 422 : 409, {
|
|
379
|
+
error: outcome === 'pending' ? 'dependency_outcome_pending' : outcome === 'unknown' ? 'dependency_outcome_unknown' : 'dependency_not_dispatched',
|
|
380
|
+
outcome, request_id: original.request_id, recovery_path: this.#localPath(alias, original.request_id),
|
|
381
|
+
...(original.key ? { idempotency_key: original.key } : {}), message: UNSETTLED_TEXT[outcome],
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
#localPath(alias, requestId) {
|
|
385
|
+
return `/${this.#cap}/${alias}/v1/requests/${requestId}`;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Root's recovery data for one accepted operation, kept only when its path is
|
|
389
|
+
* exactly this dependency's original-operation route (so the proxy never reads
|
|
390
|
+
* an arbitrary path with a token).
|
|
391
|
+
*/
|
|
392
|
+
#original(alias, kind, value, key, capture) {
|
|
393
|
+
const data = record(value);
|
|
394
|
+
const pinned = this.#pinned.get(alias);
|
|
395
|
+
if (!data || !pinned || typeof data.request_id !== 'string' || !UUID_PATTERN.test(data.request_id))
|
|
396
|
+
return null;
|
|
397
|
+
const path = `/v1/requests/${encodeURIComponent(pinned.service_key)}/${data.request_id}`;
|
|
398
|
+
if (data.recovery_path !== path)
|
|
399
|
+
return null;
|
|
400
|
+
let seen = this.#originals.get(alias);
|
|
401
|
+
if (!seen) {
|
|
402
|
+
seen = new Map();
|
|
403
|
+
this.#originals.set(alias, seen);
|
|
404
|
+
}
|
|
405
|
+
const original = seen.get(data.request_id) ?? { request_id: data.request_id, path, kind, key };
|
|
406
|
+
if (capture && !original.capture) {
|
|
407
|
+
const size = Buffer.byteLength(JSON.stringify(capture.request)) + 1024;
|
|
408
|
+
// Evidence context is optional; it must not multiply the 10k original
|
|
409
|
+
// recovery index into an unbounded body cache. Never evict recovery IDs.
|
|
410
|
+
if (this.#retainedCaptureBytes + size <= 2 * 1048576) {
|
|
411
|
+
original.capture = capture;
|
|
412
|
+
this.#retainedCaptureBytes += size;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (key && !original.key)
|
|
416
|
+
original.key = key;
|
|
417
|
+
seen.delete(original.request_id);
|
|
418
|
+
if (seen.size >= ORIGINALS_MAX)
|
|
419
|
+
seen.delete(seen.keys().next().value);
|
|
420
|
+
seen.set(original.request_id, original);
|
|
421
|
+
return original;
|
|
422
|
+
}
|
|
423
|
+
/** One read of an original operation: root's path, on the origin of the granted URL that accepted it, with a current token. */
|
|
424
|
+
#read(alias, original) {
|
|
425
|
+
return this.#forward(alias, (access) => get(new URL(original.path, original.kind === 'invoke' ? access.invoke_url : access.mcp_url).href, access.token, this.#controller.signal));
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Read the original operation until it is no longer pending or the wait ends.
|
|
429
|
+
* Returns the last read (possibly still pending), or a refusal code when no read
|
|
430
|
+
* could be completed.
|
|
431
|
+
*/
|
|
432
|
+
async #settle(alias, original, retryAfter) {
|
|
433
|
+
const deadline = performance.now() + PENDING_WAIT_MS;
|
|
434
|
+
let delay = Math.min(5, Math.max(1, retryAfter || 1));
|
|
435
|
+
for (;;) {
|
|
436
|
+
if (!await sleep(delay * 1_000, this.#controller.signal))
|
|
437
|
+
return 'dependency_unavailable';
|
|
438
|
+
const read = await this.#read(alias, original);
|
|
439
|
+
if (typeof read === 'string')
|
|
440
|
+
return read;
|
|
441
|
+
if (header(read.headers, OUTCOME) !== 'pending' || performance.now() + 1_000 >= deadline)
|
|
442
|
+
return read;
|
|
443
|
+
delay = Math.min(5, Math.max(1, Number(header(read.headers, 'retry-after')) || 1));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async #mcp(alias, message, response) {
|
|
447
|
+
const rpc = record(message);
|
|
448
|
+
if (!rpc || rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string' || !Object.keys(rpc).every((key) => RPC_KEYS.has(key))
|
|
449
|
+
|| (rpc.params !== undefined && !record(rpc.params))
|
|
450
|
+
|| (rpc.id !== undefined && rpc.id !== null && typeof rpc.id !== 'string' && !Number.isSafeInteger(rpc.id))) {
|
|
451
|
+
if (rpc?.method === 'tools/call')
|
|
452
|
+
this.#captureUnavailable();
|
|
453
|
+
return refuse(response, 400, 'request_invalid');
|
|
454
|
+
}
|
|
455
|
+
if (rpc.id === undefined) {
|
|
456
|
+
if (rpc.method === 'tools/call')
|
|
457
|
+
this.#captureUnavailable();
|
|
458
|
+
if (rpc.method !== 'notifications/initialized')
|
|
459
|
+
return refuse(response, 400, 'request_id_required');
|
|
460
|
+
response.writeHead(202, { 'cache-control': 'no-store', 'content-length': '0' });
|
|
461
|
+
response.end();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const id = rpc.id;
|
|
465
|
+
const params = record(rpc.params) ?? {};
|
|
466
|
+
// Root's gateway answers these identically; answering here lets the target connect before admission.
|
|
467
|
+
if (rpc.method === 'initialize')
|
|
468
|
+
return rpcReply(response, id, { result: MCP_INITIALIZE });
|
|
469
|
+
if (rpc.method === 'ping')
|
|
470
|
+
return rpcReply(response, id, { result: {} });
|
|
471
|
+
const declared = this.#operations.get(alias);
|
|
472
|
+
let forwarded;
|
|
473
|
+
let key = null;
|
|
474
|
+
if (rpc.method === 'tools/call') {
|
|
475
|
+
if (typeof params.name !== 'string' || !declared.has(params.name)) {
|
|
476
|
+
this.#captureUnavailable();
|
|
477
|
+
return rpcReply(response, id, { error: { code: -32602, message: 'The tool is not declared for this target.' } });
|
|
478
|
+
}
|
|
479
|
+
if ((params.arguments !== undefined && !record(params.arguments)) || (params._meta !== undefined && !record(params._meta))) {
|
|
480
|
+
this.#captureUnavailable();
|
|
481
|
+
return rpcReply(response, id, { error: { code: -32602, message: 'Use a tool name and an arguments object.' } });
|
|
482
|
+
}
|
|
483
|
+
// The tool's declared idempotency argument (root's tools/list metadata) carries the same key as _meta.
|
|
484
|
+
const argument = await this.#idempotencyArgument(alias, params.name);
|
|
485
|
+
if (argument === undefined) {
|
|
486
|
+
this.#captureUnavailable();
|
|
487
|
+
return rpcReply(response, id, { error: { code: -32603, message: 'The simulated dependency\'s tool list could not be read, so nothing was sent. Try again.' } });
|
|
488
|
+
}
|
|
489
|
+
const meta = record(params._meta) ?? {};
|
|
490
|
+
const keyed = keyedCall(record(params.arguments) ?? {}, meta, argument);
|
|
491
|
+
if (!keyed) {
|
|
492
|
+
this.#captureUnavailable();
|
|
493
|
+
return rpcReply(response, id, { error: { code: -32602,
|
|
494
|
+
message: `The ${argument} argument and _meta ${IDEMPOTENCY_META} name different keys, so nothing was sent. Use one key.` } });
|
|
495
|
+
}
|
|
496
|
+
key = keyed.key;
|
|
497
|
+
this.#beginCapture(alias, params.name, keyed.arguments, argument, 'mcp', key, response);
|
|
498
|
+
forwarded = { jsonrpc: '2.0', id, method: 'tools/call',
|
|
499
|
+
params: { ...params, arguments: keyed.arguments, _meta: { ...meta, [IDEMPOTENCY_META]: key } } };
|
|
500
|
+
}
|
|
501
|
+
else if (rpc.method === 'tools/list') {
|
|
502
|
+
forwarded = { jsonrpc: '2.0', id, method: 'tools/list', params };
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
return rpcReply(response, id, { error: { code: -32601, message: 'Method not found' } });
|
|
506
|
+
}
|
|
507
|
+
const bytes = Buffer.from(JSON.stringify(forwarded), 'utf8');
|
|
508
|
+
const send = () => this.#forward(alias, (access) => post(access.mcp_url, access.token, true, bytes, this.#controller.signal));
|
|
509
|
+
const upstream = await send();
|
|
510
|
+
// A gateway refusal (root answers HTTP 409, not JSON-RPC) becomes a tool error, not a transport failure.
|
|
511
|
+
if (typeof upstream === 'string' || upstream.status !== 200) {
|
|
512
|
+
// A tools/call may have been accepted before the answer was lost: say so, and give the key that replays it.
|
|
513
|
+
if (key)
|
|
514
|
+
return rpcReply(response, id, { error: { code: -32603, message: UNANSWERED_TEXT, data: { 'firedrill/idempotency-key': key } } });
|
|
515
|
+
const text = upstream === 'dependency_not_ready' ? 'The simulated dependency is not ready yet.' : 'The simulated dependency refused the call.';
|
|
516
|
+
return rpcReply(response, id, { error: { code: -32603, message: text } });
|
|
517
|
+
}
|
|
518
|
+
let reply = record(parsed(upstream.body));
|
|
519
|
+
if (!reply || reply.jsonrpc !== '2.0')
|
|
520
|
+
return rpcReply(response, id, { error: { code: -32603, message: 'The simulated dependency answered malformed JSON.' } });
|
|
521
|
+
const failure = record(reply.error);
|
|
522
|
+
if (key && failure?.code === NO_CONFIRMED_RESULT)
|
|
523
|
+
return this.#mcpUnconfirmed(alias, id, key, record(failure.data), send, response);
|
|
524
|
+
if (rpc.method === 'tools/list' && record(reply.result)) {
|
|
525
|
+
this.#remember(alias, listedArguments(reply.result)?.tools);
|
|
526
|
+
// Least privilege: list only what the descriptor declares (root lists the whole grant).
|
|
527
|
+
const tools = Array.isArray(record(reply.result)?.tools) ? record(reply.result)?.tools : [];
|
|
528
|
+
reply = { ...reply, result: { ...record(reply.result), tools: tools.filter((tool) => {
|
|
529
|
+
const name = record(tool)?.name;
|
|
530
|
+
return typeof name === 'string' && declared.has(name);
|
|
531
|
+
}) } };
|
|
532
|
+
}
|
|
533
|
+
return json(response, 200, { ...reply, id });
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A broker `tools/call` without a confirmed result: wait on the original
|
|
537
|
+
* operation and, once it is observed, send the same call (same key) again. The
|
|
538
|
+
* broker then replays the original command as an MCP result, whatever the
|
|
539
|
+
* tool's own transport; the recovery read returns an HTTP-backed tool's raw
|
|
540
|
+
* answer, which is not an MCP result. Otherwise keep the JSON-RPC error with
|
|
541
|
+
* loopback recovery data and the key. Never a fabricated tool result.
|
|
542
|
+
*/
|
|
543
|
+
async #mcpUnconfirmed(alias, id, key, data, send, response) {
|
|
544
|
+
const original = this.#original(alias, 'mcp', data, key, this.#captureContexts.get(response));
|
|
545
|
+
if (!original) {
|
|
546
|
+
return rpcReply(response, id, { error: { code: -32603, message: UNANSWERED_TEXT, data: { 'firedrill/idempotency-key': key } } });
|
|
547
|
+
}
|
|
548
|
+
const said = data?.outcome;
|
|
549
|
+
let outcome = said === 'pending' || said === 'not_dispatched' ? said : 'unknown';
|
|
550
|
+
if (outcome === 'pending') {
|
|
551
|
+
const settled = await this.#settle(alias, original, 1);
|
|
552
|
+
const now = typeof settled === 'string' ? null : header(settled.headers, OUTCOME);
|
|
553
|
+
if (now === 'observed' || now === 'unknown' || now === 'not_dispatched')
|
|
554
|
+
outcome = now;
|
|
555
|
+
}
|
|
556
|
+
if (outcome === 'observed') {
|
|
557
|
+
const replayed = await send();
|
|
558
|
+
const reply = typeof replayed === 'string' || replayed.status !== 200 ? null : record(parsed(replayed.body));
|
|
559
|
+
const result = reply?.jsonrpc === '2.0' ? record(reply.result) : null;
|
|
560
|
+
if (result && Array.isArray(result.content))
|
|
561
|
+
return rpcReply(response, id, { result });
|
|
562
|
+
}
|
|
563
|
+
return rpcReply(response, id, { error: { code: NO_CONFIRMED_RESULT, message: outcome === 'observed' ? UNDELIVERED_TEXT : UNSETTLED_TEXT[outcome],
|
|
564
|
+
data: { outcome, request_id: original.request_id, recovery_path: this.#localPath(alias, original.request_id), 'firedrill/idempotency-key': key } } });
|
|
565
|
+
}
|
|
566
|
+
#captureUnavailable() {
|
|
567
|
+
try {
|
|
568
|
+
this.#options.onExchange?.(null);
|
|
569
|
+
}
|
|
570
|
+
catch { /* Optional capture only. */ }
|
|
571
|
+
}
|
|
572
|
+
#beginCapture(alias, operation, args, argument, protocol, key, response) {
|
|
573
|
+
if (!this.#options.onExchange)
|
|
574
|
+
return;
|
|
575
|
+
try {
|
|
576
|
+
const business = { ...args };
|
|
577
|
+
for (const name of ['_meta', 'idempotency_key', 'firedrill/idempotency-key', ...(argument ? [argument] : [])])
|
|
578
|
+
delete business[name];
|
|
579
|
+
const scrubbed = scrubExchange(business, [this.#cap, key ?? '']);
|
|
580
|
+
const filtered = filterEvidenceValue(scrubbed.value, 1, 2048, 1024);
|
|
581
|
+
const bounded = record(filtered.value) ?? {};
|
|
582
|
+
const over = Buffer.byteLength(JSON.stringify(bounded)) > 32768;
|
|
583
|
+
this.#attachCapture({ alias, operation, protocol, key, request: over ? {} : bounded,
|
|
584
|
+
request_coverage: over || (filtered.stats.truncated ?? 0) > 0 ? 'truncated'
|
|
585
|
+
: scrubbed.changed || filtered.stats.removed + filtered.stats.rewritten ? 'redacted' : 'captured' }, response);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
this.#captureUnavailable();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
#attachCapture(context, response) {
|
|
592
|
+
if (!this.#options.onExchange)
|
|
593
|
+
return;
|
|
594
|
+
this.#captureContexts.set(response, context);
|
|
595
|
+
captures.set(response, (status, observed, format) => {
|
|
596
|
+
try {
|
|
597
|
+
const binding = this.#pinned.get(context.alias);
|
|
598
|
+
if (!binding) {
|
|
599
|
+
this.#captureUnavailable();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
let value = observed, isError = null;
|
|
603
|
+
let outcome = status === null ? 'unknown' : 'returned';
|
|
604
|
+
const object = record(observed);
|
|
605
|
+
const error = record(object?.error);
|
|
606
|
+
const data = record(error?.data);
|
|
607
|
+
const said = data?.outcome ?? object?.outcome ?? header(response.getHeaders(), OUTCOME);
|
|
608
|
+
if (said === 'pending')
|
|
609
|
+
outcome = 'pending';
|
|
610
|
+
else if (said === 'unknown')
|
|
611
|
+
outcome = 'unknown';
|
|
612
|
+
else if (said === 'not_dispatched')
|
|
613
|
+
outcome = 'refused';
|
|
614
|
+
if (context.protocol === 'mcp') {
|
|
615
|
+
isError = format === 'unavailable' ? null : !!error || record(object?.result)?.isError === true;
|
|
616
|
+
// Strip RPC envelope and all recovery/transport error data.
|
|
617
|
+
value = error ? { code: error.code, message: error.message } : object?.result ?? null;
|
|
618
|
+
// Internal transport/malformed replies do not prove the operation
|
|
619
|
+
// was refused. In particular, an observed broker result whose MCP
|
|
620
|
+
// replay was lost is still unknown at this device.
|
|
621
|
+
if (error && outcome === 'returned')
|
|
622
|
+
outcome = error.code === -32603 || error.code === NO_CONFIRMED_RESULT ? 'unknown' : 'refused';
|
|
623
|
+
}
|
|
624
|
+
else if (object && ('idempotency_key' in object || 'recovery_path' in object)) {
|
|
625
|
+
value = null;
|
|
626
|
+
format = 'unavailable';
|
|
627
|
+
if (outcome === 'returned')
|
|
628
|
+
outcome = 'unknown';
|
|
629
|
+
}
|
|
630
|
+
if (outcome === 'pending' || outcome === 'unknown') {
|
|
631
|
+
value = null;
|
|
632
|
+
format = 'unavailable';
|
|
633
|
+
}
|
|
634
|
+
const omissions = [];
|
|
635
|
+
if (context.request_coverage === 'redacted')
|
|
636
|
+
omissions.push('privacy_filtered');
|
|
637
|
+
if (context.request_coverage === 'truncated')
|
|
638
|
+
omissions.push('size_limit');
|
|
639
|
+
// Never retain a returned capability/key even if a tool echoes one.
|
|
640
|
+
const encoded = JSON.stringify(value);
|
|
641
|
+
let rewritten = false;
|
|
642
|
+
if (encoded === undefined) {
|
|
643
|
+
value = null;
|
|
644
|
+
format = 'unavailable';
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
if (Buffer.byteLength(encoded) > 131072) {
|
|
648
|
+
value = null;
|
|
649
|
+
format = 'unavailable';
|
|
650
|
+
omissions.push('size_limit');
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
const scrubbed = scrubExchange(value, [this.#cap, context.key ?? '']);
|
|
654
|
+
value = scrubbed.value;
|
|
655
|
+
rewritten = scrubbed.changed;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (rewritten)
|
|
659
|
+
omissions.push('privacy_filtered');
|
|
660
|
+
if (format === 'unavailable')
|
|
661
|
+
omissions.push(status === null ? 'no_response' : 'unsupported_content');
|
|
662
|
+
this.#options.onExchange?.({ schema: 'firedrill.native-dependency-exchange.v1', exchange_id: randomUUID(),
|
|
663
|
+
dependency_alias: context.alias, service_key: binding.service_key, operation: context.operation,
|
|
664
|
+
binding_digest: binding.binding_digest, protocol: context.protocol, outcome, http_status: status, is_error: isError,
|
|
665
|
+
request: context.request, response: value, response_format: format, request_coverage: context.request_coverage,
|
|
666
|
+
response_coverage: format === 'unavailable' ? 'unavailable' : rewritten ? 'redacted' : 'captured',
|
|
667
|
+
omission_reasons: [...new Set(omissions)] });
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
this.#captureUnavailable();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
response.once('close', () => { if (captures.has(response))
|
|
674
|
+
captured(response, null, null, 'unavailable'); });
|
|
675
|
+
}
|
|
676
|
+
#remember(alias, tools) {
|
|
677
|
+
if (!tools)
|
|
678
|
+
return;
|
|
679
|
+
let known = this.#arguments.get(alias);
|
|
680
|
+
if (!known) {
|
|
681
|
+
known = new Map();
|
|
682
|
+
this.#arguments.set(alias, known);
|
|
683
|
+
}
|
|
684
|
+
for (const [name, argument] of tools)
|
|
685
|
+
known.set(name, argument);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* A tool's declared idempotency argument (null when it declares none), read
|
|
689
|
+
* from root's tools/list once per dependency, single-flight. Undefined when the
|
|
690
|
+
* list could not be read: the call is not sent rather than sent unshaped.
|
|
691
|
+
*/
|
|
692
|
+
async #idempotencyArgument(alias, tool) {
|
|
693
|
+
const known = this.#arguments.get(alias);
|
|
694
|
+
if (known?.has(tool))
|
|
695
|
+
return known.get(tool);
|
|
696
|
+
if (this.#discovered.has(alias))
|
|
697
|
+
return null;
|
|
698
|
+
let pending = this.#discovering.get(alias);
|
|
699
|
+
if (!pending) {
|
|
700
|
+
pending = this.#discover(alias).finally(() => this.#discovering.delete(alias));
|
|
701
|
+
this.#discovering.set(alias, pending);
|
|
702
|
+
}
|
|
703
|
+
if (!await pending)
|
|
704
|
+
return undefined;
|
|
705
|
+
return this.#arguments.get(alias)?.get(tool) ?? null;
|
|
706
|
+
}
|
|
707
|
+
async #discover(alias) {
|
|
708
|
+
let cursor = null;
|
|
709
|
+
for (let page = 0; page < DISCOVERY_PAGES; page++) {
|
|
710
|
+
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: `firedrill-discovery-${page + 1}`, method: 'tools/list',
|
|
711
|
+
params: cursor === null ? {} : { cursor } }), 'utf8');
|
|
712
|
+
const upstream = await this.#forward(alias, (access) => post(access.mcp_url, access.token, true, body, this.#controller.signal));
|
|
713
|
+
if (typeof upstream === 'string' || upstream.status !== 200)
|
|
714
|
+
return false;
|
|
715
|
+
const reply = record(parsed(upstream.body));
|
|
716
|
+
const listed = reply?.jsonrpc === '2.0' ? listedArguments(reply.result) : null;
|
|
717
|
+
if (!listed)
|
|
718
|
+
return false;
|
|
719
|
+
this.#remember(alias, listed.tools);
|
|
720
|
+
if (listed.next === null)
|
|
721
|
+
break;
|
|
722
|
+
cursor = listed.next;
|
|
723
|
+
}
|
|
724
|
+
// A list longer than the pages read leaves the rest undeclared here; root's guards still refuse a mismatch.
|
|
725
|
+
this.#discovered.add(alias);
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
// -------------------------------------------------------------------------
|
|
729
|
+
// Outbound (root's tool gateway).
|
|
730
|
+
/**
|
|
731
|
+
* Send one call upstream with a token, retried at most once with a fresh token
|
|
732
|
+
* and the same request (same idempotency key; a read is safe to repeat). Returns
|
|
733
|
+
* the gateway's answer, or a short local refusal code when no answer can be relayed.
|
|
734
|
+
*/
|
|
735
|
+
async #forward(alias, send, serviceKey) {
|
|
736
|
+
let fresh = false;
|
|
737
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
738
|
+
let access;
|
|
739
|
+
try {
|
|
740
|
+
access = await this.#token(alias, fresh);
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return this.#closed ? 'dependency_unavailable' : 'dependency_not_ready';
|
|
744
|
+
}
|
|
745
|
+
if (serviceKey !== undefined && serviceKey !== access.service_key)
|
|
746
|
+
return 'service_key_mismatch';
|
|
747
|
+
let upstream;
|
|
748
|
+
try {
|
|
749
|
+
upstream = await send(access);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// No complete answer: retry once with a fresh token; the same key makes a committed write replay.
|
|
753
|
+
if (attempt === 0 && !this.#closed) {
|
|
754
|
+
fresh = true;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
return 'dependency_unavailable';
|
|
758
|
+
}
|
|
759
|
+
const code = gatewayCode(upstream.body);
|
|
760
|
+
const refused = upstream.status === 401 || (upstream.status === 409 && code === 'tool_request_unavailable');
|
|
761
|
+
const overloaded = upstream.status === 503 && code === 'tool_capacity_unavailable';
|
|
762
|
+
if ((refused || overloaded) && attempt === 0 && !this.#closed) {
|
|
763
|
+
if (overloaded) {
|
|
764
|
+
const wait = Math.min(5, Math.max(1, Number(header(upstream.headers, 'retry-after')) || 1));
|
|
765
|
+
if (!await sleep(wait * 1_000, this.#controller.signal))
|
|
766
|
+
return 'dependency_unavailable';
|
|
767
|
+
}
|
|
768
|
+
fresh = refused;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
return upstream;
|
|
772
|
+
}
|
|
773
|
+
return 'dependency_unavailable';
|
|
774
|
+
}
|
|
775
|
+
/** A usable token for one alias: cached while valid (a freshly issued one is used at least once), else fetched single-flight. */
|
|
776
|
+
async #token(alias, fresh) {
|
|
777
|
+
const cached = this.#tokens.get(alias);
|
|
778
|
+
if (cached && !fresh && (performance.now() < cached.validUntil || !cached.used)) {
|
|
779
|
+
cached.used = true;
|
|
780
|
+
return cached.access;
|
|
781
|
+
}
|
|
782
|
+
let pending = this.#fetching.get(alias);
|
|
783
|
+
if (!pending) {
|
|
784
|
+
pending = this.#fetch(alias).finally(() => this.#fetching.delete(alias));
|
|
785
|
+
this.#fetching.set(alias, pending);
|
|
786
|
+
}
|
|
787
|
+
const access = await pending;
|
|
788
|
+
const entry = this.#tokens.get(alias);
|
|
789
|
+
if (entry?.access === access)
|
|
790
|
+
entry.used = true;
|
|
791
|
+
return access;
|
|
792
|
+
}
|
|
793
|
+
async #fetch(alias) {
|
|
794
|
+
const deadline = performance.now() + TOKEN_WAIT_MS;
|
|
795
|
+
for (let attempt = 0;; attempt++) {
|
|
796
|
+
if (this.#closed)
|
|
797
|
+
throw new CliError('dependency_proxy_closed', 'The drill stopped.');
|
|
798
|
+
let access;
|
|
799
|
+
try {
|
|
800
|
+
access = await this.#options.controlPlane.dependencyAccess(this.#options.assignment, alias, this.#controller.signal);
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
const code = errorCode(error);
|
|
804
|
+
const status = error instanceof CliError ? error.httpStatus : null;
|
|
805
|
+
// Fenced, revoked, closed, or no gateway configured: nothing later in this drill can succeed.
|
|
806
|
+
if (isRevocation(error) || isFenced(error) || code === 'native_execution_closed'
|
|
807
|
+
|| (code === DEPENDENCY_ERRORS.unavailable && status === 503)) {
|
|
808
|
+
this.#options.onFatal(error);
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
// Not admitted yet (409) or briefly unavailable: wait within this call's bound.
|
|
812
|
+
const retryable = (code === DEPENDENCY_ERRORS.unavailable && status === 409) || isTransient(error);
|
|
813
|
+
if (!retryable || performance.now() >= deadline || this.#closed)
|
|
814
|
+
throw error;
|
|
815
|
+
if (!await sleep(Math.min(4_000, 500 * 2 ** attempt), this.#controller.signal))
|
|
816
|
+
throw error;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const pinned = this.#pinned.get(alias);
|
|
820
|
+
if (pinned && (pinned.binding_digest !== access.binding_digest || pinned.service_key !== access.service_key
|
|
821
|
+
|| pinned.invoke_url !== access.invoke_url || pinned.mcp_url !== access.mcp_url)) {
|
|
822
|
+
const changed = new CliError(DEPENDENCY_ERRORS.changed, `Simulated dependency "${alias}" changed during the drill, so the drill was stopped.`);
|
|
823
|
+
this.#options.onFatal(changed);
|
|
824
|
+
throw changed;
|
|
825
|
+
}
|
|
826
|
+
if (!pinned) {
|
|
827
|
+
this.#pinned.set(alias, { binding_digest: access.binding_digest, service_key: access.service_key,
|
|
828
|
+
invoke_url: access.invoke_url, mcp_url: access.mcp_url });
|
|
829
|
+
}
|
|
830
|
+
// Monotonic window from receipt time, so a wall-clock jump never extends a token's use.
|
|
831
|
+
const lifetime = Math.min(30_000, access.expires_at * 1_000 - Date.now()) - TOKEN_MARGIN_MS;
|
|
832
|
+
this.#tokens.set(alias, { access, validUntil: performance.now() + lifetime, used: false });
|
|
833
|
+
return access;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
/** One POST to root's gateway with exactly the headers it accepts; the body is fully read (bounded). */
|
|
839
|
+
export function post(url, token, mcp, body, signal) {
|
|
840
|
+
const headers = { 'content-type': 'application/json', 'content-length': String(body.length) };
|
|
841
|
+
if (mcp) {
|
|
842
|
+
headers.accept = 'application/json, text/event-stream';
|
|
843
|
+
headers['mcp-protocol-version'] = MCP_PROTOCOL_VERSION;
|
|
844
|
+
}
|
|
845
|
+
return send('POST', url, token, headers, body, signal);
|
|
846
|
+
}
|
|
847
|
+
/** One GET of an original operation on the isolated tool broker: no body, no query, no Origin or cookie. */
|
|
848
|
+
export function get(url, token, signal) {
|
|
849
|
+
return send('GET', url, token, {}, null, signal);
|
|
850
|
+
}
|
|
851
|
+
function send(method, url, token, extra, body, signal) {
|
|
852
|
+
return new Promise((resolve, reject) => {
|
|
853
|
+
const target = new URL(url);
|
|
854
|
+
if (target.protocol !== 'https:') {
|
|
855
|
+
reject(new Error('insecure gateway'));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (method === 'GET' && target.search !== '') {
|
|
859
|
+
reject(new Error('query not allowed'));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const headers = { authorization: `Bearer ${token.reveal()}`, ...extra };
|
|
863
|
+
const request = httpsRequest(target, { method, headers, signal: AbortSignal.any([signal, AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)]) }, (response) => {
|
|
864
|
+
const chunks = [];
|
|
865
|
+
let size = 0;
|
|
866
|
+
response.on('data', (chunk) => {
|
|
867
|
+
size += chunk.length;
|
|
868
|
+
if (size > RESPONSE_MAX_BYTES) {
|
|
869
|
+
response.destroy(new Error('response too large'));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
chunks.push(chunk);
|
|
873
|
+
});
|
|
874
|
+
response.once('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(chunks) }));
|
|
875
|
+
response.once('error', reject);
|
|
876
|
+
response.once('aborted', () => reject(new Error('response aborted')));
|
|
877
|
+
});
|
|
878
|
+
request.once('error', reject);
|
|
879
|
+
if (body === null)
|
|
880
|
+
request.end();
|
|
881
|
+
else
|
|
882
|
+
request.end(body);
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/** A JSON body, or null. */
|
|
886
|
+
function parsed(bytes) {
|
|
887
|
+
try {
|
|
888
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
export function record(value) {
|
|
895
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
896
|
+
}
|
|
897
|
+
export function header(headers, name) {
|
|
898
|
+
const value = headers[name];
|
|
899
|
+
return typeof value === 'string' ? value : Array.isArray(value) ? value[0] ?? null : null;
|
|
900
|
+
}
|
|
901
|
+
/** Root's idempotency key format: 1..255 visible ASCII. */
|
|
902
|
+
export function validKey(value) {
|
|
903
|
+
return typeof value === 'string' && /^[\x21-\x7e]{1,255}$/.test(value);
|
|
904
|
+
}
|
|
905
|
+
/** Root's MCP request key in `tools/call` `_meta`. */
|
|
906
|
+
export const IDEMPOTENCY_META = 'firedrill/idempotency-key';
|
|
907
|
+
/** Root's `tools/list` `_meta` naming a tool's declared transport idempotency argument (pinned operation contract). */
|
|
908
|
+
export const IDEMPOTENCY_ARGUMENT = 'firedrill/idempotency-argument';
|
|
909
|
+
/** Pages one proxy reads to learn the declared idempotency arguments (the broker lists 50 tools per page). */
|
|
910
|
+
export const DISCOVERY_PAGES = 20;
|
|
911
|
+
/**
|
|
912
|
+
* Each listed tool's declared idempotency argument, or null when it declares
|
|
913
|
+
* none. Returns null for a malformed list.
|
|
914
|
+
*/
|
|
915
|
+
export function listedArguments(result) {
|
|
916
|
+
const value = result && typeof result === 'object' && !Array.isArray(result) ? result : null;
|
|
917
|
+
if (!value || !Array.isArray(value.tools))
|
|
918
|
+
return null;
|
|
919
|
+
const tools = new Map();
|
|
920
|
+
for (const tool of value.tools) {
|
|
921
|
+
const row = tool && typeof tool === 'object' && !Array.isArray(tool) ? tool : null;
|
|
922
|
+
if (!row || typeof row.name !== 'string')
|
|
923
|
+
continue;
|
|
924
|
+
const meta = row._meta && typeof row._meta === 'object' && !Array.isArray(row._meta) ? row._meta : {};
|
|
925
|
+
const argument = meta[IDEMPOTENCY_ARGUMENT];
|
|
926
|
+
tools.set(row.name, typeof argument === 'string' && argument.length > 0 ? argument : null);
|
|
927
|
+
}
|
|
928
|
+
return { tools, next: typeof value.nextCursor === 'string' && value.nextCursor.length > 0 ? value.nextCursor : null };
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The request key and arguments for one `tools/call`, following root's
|
|
932
|
+
* declared idempotency argument:
|
|
933
|
+
* - a valid key the caller put in that argument is the request key; a different
|
|
934
|
+
* explicit `_meta` key is a conflict (null);
|
|
935
|
+
* - when the argument is absent, only that field is filled, with the same key
|
|
936
|
+
* as `_meta` (the caller's, or a new one);
|
|
937
|
+
* - an argument value that is not a valid key is left as it is for root's guards;
|
|
938
|
+
* - no other field is ever changed, and an existing value is never replaced.
|
|
939
|
+
*/
|
|
940
|
+
export function keyedCall(args, meta, argument) {
|
|
941
|
+
const given = validKey(meta[IDEMPOTENCY_META]) ? meta[IDEMPOTENCY_META] : null;
|
|
942
|
+
if (argument === null)
|
|
943
|
+
return { key: given ?? randomUUID(), arguments: args };
|
|
944
|
+
if (!Object.hasOwn(args, argument)) {
|
|
945
|
+
const key = given ?? randomUUID();
|
|
946
|
+
return { key, arguments: { ...args, [argument]: key } };
|
|
947
|
+
}
|
|
948
|
+
const explicit = args[argument];
|
|
949
|
+
if (!validKey(explicit))
|
|
950
|
+
return { key: given ?? randomUUID(), arguments: args };
|
|
951
|
+
if (given !== null && given !== explicit)
|
|
952
|
+
return null;
|
|
953
|
+
return { key: explicit, arguments: args };
|
|
954
|
+
}
|
|
955
|
+
export function sameCap(given, expected) {
|
|
956
|
+
const a = Buffer.from(given), b = Buffer.from(expected);
|
|
957
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
958
|
+
}
|
|
959
|
+
/** The gateway's error code: flat `{"error":"<code>"}` (pooled gateway) or `{"error":{"code"}}` (isolated tool broker). */
|
|
960
|
+
export function gatewayCode(bytes) {
|
|
961
|
+
if (bytes.length > 4_096)
|
|
962
|
+
return null;
|
|
963
|
+
const value = record(parsed(bytes));
|
|
964
|
+
if (typeof value?.error === 'string')
|
|
965
|
+
return value.error;
|
|
966
|
+
const nested = record(value?.error);
|
|
967
|
+
return typeof nested?.code === 'string' ? nested.code : null;
|
|
968
|
+
}
|
|
969
|
+
/** The request body, or null once it exceeds root's limit (the rest is drained, not buffered). */
|
|
970
|
+
export function readBody(request) {
|
|
971
|
+
return new Promise((resolve, reject) => {
|
|
972
|
+
const chunks = [];
|
|
973
|
+
let size = 0;
|
|
974
|
+
let over = false;
|
|
975
|
+
request.on('data', (chunk) => {
|
|
976
|
+
if (over)
|
|
977
|
+
return;
|
|
978
|
+
size += chunk.length;
|
|
979
|
+
if (size > BODY_MAX_BYTES) {
|
|
980
|
+
over = true;
|
|
981
|
+
chunks.length = 0;
|
|
982
|
+
resolve(null);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
chunks.push(chunk);
|
|
986
|
+
});
|
|
987
|
+
request.once('end', () => { if (!over)
|
|
988
|
+
resolve(Buffer.concat(chunks)); });
|
|
989
|
+
request.once('error', reject);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
export function json(response, status, value, close = false) {
|
|
993
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8');
|
|
994
|
+
response.writeHead(status, { 'content-type': 'application/json', 'content-length': String(body.length), 'cache-control': 'no-store',
|
|
995
|
+
...(close ? { connection: 'close' } : {}) });
|
|
996
|
+
captured(response, status, body.length <= 131072 ? value : null, body.length <= 131072 ? 'json' : 'unavailable');
|
|
997
|
+
response.end(body);
|
|
998
|
+
}
|
|
999
|
+
export function rpcReply(response, id, payload) {
|
|
1000
|
+
json(response, 200, { jsonrpc: '2.0', id, ...payload });
|
|
1001
|
+
}
|
|
1002
|
+
export function refuse(response, status, code, retryAfter, close = false) {
|
|
1003
|
+
if (retryAfter !== undefined)
|
|
1004
|
+
response.setHeader('retry-after', String(retryAfter));
|
|
1005
|
+
json(response, status, { error: code }, close);
|
|
1006
|
+
}
|