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,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local access to started standalone tools (`firedrill tools access`).
|
|
3
|
+
*
|
|
4
|
+
* Root's terminal credential for a standalone instance lives at most 30 seconds
|
|
5
|
+
* and must stay in this process's memory, so an agent on this machine is given
|
|
6
|
+
* stable loopback URLs instead and this proxy fetches credentials lazily and
|
|
7
|
+
* forwards each call to the instance's isolated tool host:
|
|
8
|
+
* - `POST /{cap}/{instance}/v1/invoke`: the Firedrill operation protocol
|
|
9
|
+
* `{service_key?, operation, arguments, idempotency_key?}`;
|
|
10
|
+
* - `POST /{cap}/{instance}/mcp`: JSON-response MCP (`initialize`, `ping`,
|
|
11
|
+
* `tools/list`, `tools/call`); notifications are accepted locally. Every
|
|
12
|
+
* `tools/call` carries an idempotency key in `_meta`, because a generic MCP
|
|
13
|
+
* client cannot add one.
|
|
14
|
+
* The operations an agent can call are exactly what root's credential grants
|
|
15
|
+
* for the instance and mode (`read` grants read-only operations), as the tool
|
|
16
|
+
* host's tools/list shows; any other operation is refused here and never sent.
|
|
17
|
+
* On both routes an operation's declared idempotency argument carries the same
|
|
18
|
+
* key as the call's idempotency key.
|
|
19
|
+
*
|
|
20
|
+
* Guards (shared with the drill dependency proxy): loopback only, exact Host,
|
|
21
|
+
* no Origin, a random path capability compared in constant time, root's 1 MiB
|
|
22
|
+
* body limit. A call is retried at most once, with a fresh credential and the
|
|
23
|
+
* same idempotency key, so a committed write replays and never happens twice.
|
|
24
|
+
* The credential is never printed, logged or written anywhere.
|
|
25
|
+
*
|
|
26
|
+
* An isolated child of a tool setup is served on the same server with its own
|
|
27
|
+
* scoped connection (`isolated-access.ts`), passed through as root defines it:
|
|
28
|
+
* - `POST|DELETE /{cap}/{instance}/mcp`: root's Streamable HTTP MCP (sessions,
|
|
29
|
+
* event-stream answers); the MCP headers are passed on, never a cookie or Origin;
|
|
30
|
+
* - `POST /{cap}/{instance}/operations/{service_key}/{operation_name}` with
|
|
31
|
+
* `{"arguments": {...}}` and an `Idempotency-Key` UUID (one is made and
|
|
32
|
+
* returned when absent); `GET /{cap}/{instance}/requests/{key}` reads it back.
|
|
33
|
+
* Nothing is retried there: root keys each call by its original request, so a
|
|
34
|
+
* lost answer is read back or resent with the same key, never a new one.
|
|
35
|
+
*/
|
|
36
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
37
|
+
import { createServer } from 'node:http';
|
|
38
|
+
import { performance } from 'node:perf_hooks';
|
|
39
|
+
import { CliError } from '../http.js';
|
|
40
|
+
import { errorCode, isTransient, sleep } from './control-plane.js';
|
|
41
|
+
import { BODY_MAX_BYTES, IDEMPOTENCY_META, gatewayCode, json, keyedCall, listedArguments, post, readBody, record, refuse, rpcReply, sameCap, validKey, } from './dependency-proxy.js';
|
|
42
|
+
import { getStandaloneInstance, standaloneCredential, STANDALONE_ACTIVE } from './standalone-tools.js';
|
|
43
|
+
import { CLEANUP_MS, ISOLATED_BODY_MAX_BYTES, MCP_REQUEST_HEADERS, MCP_RESPONSE_HEADERS, OPERATION_RESPONSE_HEADERS, forwardIsolated, isolatedGrantState, pickHeaders, readBounded, } from './isolated-access.js';
|
|
44
|
+
const ROUTE = /^\/([A-Za-z0-9_-]{43})\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/(v1\/invoke|mcp|operations\/([a-z][a-z0-9_-]{0,79})\/([A-Za-z][A-Za-z0-9_.-]{0,127})|requests\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))$/;
|
|
45
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
46
|
+
/** An isolated operation that got no answer: root keeps the original under its key. */
|
|
47
|
+
const ISOLATED_UNANSWERED_TEXT = 'The tool did not answer this call, so it may or may not have taken effect. Read it back at the requests URL with the same idempotency key, or resend it with that same key. Never resend it under a new key.';
|
|
48
|
+
/** An isolated MCP tools/call that got no answer: root keys it by the MCP session and the request id. */
|
|
49
|
+
const ISOLATED_RPC_UNANSWERED_TEXT = 'The tool did not answer this call, so it may or may not have taken effect. Resend it with the same request id in the same MCP session; the tool returns the original result and never runs it twice. Never resend it with a new id.';
|
|
50
|
+
const TOKEN_MARGIN_MS = 5_000;
|
|
51
|
+
/** How long one call waits for a credential while root is briefly unavailable. */
|
|
52
|
+
const TOKEN_WAIT_MS = 30_000;
|
|
53
|
+
const INVOKE_KEYS = new Set(['service_key', 'operation', 'arguments', 'idempotency_key']);
|
|
54
|
+
const RPC_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']);
|
|
55
|
+
const FORWARDED = new Set(['initialize', 'ping', 'tools/list', 'tools/call']);
|
|
56
|
+
/** A call that may have reached the tool without an answer coming back (the tool host gives one code to every failure). */
|
|
57
|
+
const UNANSWERED_TEXT = 'The tool 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.';
|
|
58
|
+
export const STANDALONE_ACCESS_ERRORS = {
|
|
59
|
+
/** The instance is no longer active, or was reset to a new generation: start access again. */
|
|
60
|
+
ended: 'native_tool_access_ended',
|
|
61
|
+
/** Root's tool host for the instance changed between two credentials. */
|
|
62
|
+
changed: 'native_tool_access_changed',
|
|
63
|
+
/** An isolated tool's connection expired or was revoked. It is never renewed here: run access again for a new one. */
|
|
64
|
+
connectionEnded: 'tool_connection_ended',
|
|
65
|
+
};
|
|
66
|
+
/** The longest single timer Node accepts; a later expiry is checked again when it fires. */
|
|
67
|
+
const TIMER_MAX_MS = 2_147_483_647;
|
|
68
|
+
/** One grant check per isolated instance in this window, however many calls root refuses. */
|
|
69
|
+
const CONFIRM_INTERVAL_MS = 30_000;
|
|
70
|
+
export class StandaloneProxy {
|
|
71
|
+
#options;
|
|
72
|
+
#server;
|
|
73
|
+
#cap;
|
|
74
|
+
#instances;
|
|
75
|
+
#isolated;
|
|
76
|
+
#tokens = new Map();
|
|
77
|
+
#fetching = new Map();
|
|
78
|
+
#origins = new Map();
|
|
79
|
+
#ended = new Map();
|
|
80
|
+
#controller = new AbortController();
|
|
81
|
+
/** Per instance: each tool's declared idempotency argument (null: none), from root's tools/list metadata. */
|
|
82
|
+
#arguments = new Map();
|
|
83
|
+
#discovering = new Map();
|
|
84
|
+
/** Per isolated instance: the timer that ends it when its connection expires. */
|
|
85
|
+
#expiries = new Map();
|
|
86
|
+
/** Isolated instances whose grant is being checked after root refused a call, and when each was last checked. */
|
|
87
|
+
#confirming = new Set();
|
|
88
|
+
#confirmedAt = new Map();
|
|
89
|
+
#port = 0;
|
|
90
|
+
#closed = false;
|
|
91
|
+
constructor(options) {
|
|
92
|
+
this.#options = options;
|
|
93
|
+
this.#cap = randomBytes(32).toString('base64url');
|
|
94
|
+
this.#instances = new Map(options.instances.filter((one) => one.state === STANDALONE_ACTIVE).map((one) => [one.id, one]));
|
|
95
|
+
this.#isolated = new Map((options.isolated ?? []).filter((one) => !this.#instances.has(one.instance_id)).map((one) => [one.instance_id, one]));
|
|
96
|
+
for (const grant of this.#isolated.values())
|
|
97
|
+
this.#expireAt(grant);
|
|
98
|
+
this.#server = createServer({ requestTimeout: 90_000, headersTimeout: 10_000 }, (request, response) => { void this.#handle(request, response); });
|
|
99
|
+
this.#server.on('clientError', (_error, socket) => socket.destroy());
|
|
100
|
+
}
|
|
101
|
+
static async start(options) {
|
|
102
|
+
const proxy = new StandaloneProxy(options);
|
|
103
|
+
if (!proxy.serving.length)
|
|
104
|
+
throw new CliError('native_tool_access_unavailable', 'None of these tools is running, so there is nothing to connect to.', 2);
|
|
105
|
+
await new Promise((resolve, reject) => {
|
|
106
|
+
proxy.#server.once('error', reject);
|
|
107
|
+
proxy.#server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => { proxy.#server.off('error', reject); resolve(); });
|
|
108
|
+
});
|
|
109
|
+
proxy.#port = proxy.#server.address().port;
|
|
110
|
+
if (options.signal.aborted)
|
|
111
|
+
await proxy.close();
|
|
112
|
+
else
|
|
113
|
+
options.signal.addEventListener('abort', () => { void proxy.close(); }, { once: true });
|
|
114
|
+
return proxy;
|
|
115
|
+
}
|
|
116
|
+
/** The instances this proxy serves and has not ended. */
|
|
117
|
+
get serving() { return [...this.#instances.keys(), ...this.#isolated.keys()].filter((key) => !this.#ended.has(key)); }
|
|
118
|
+
/** Local URLs for one instance. They carry the path capability: they work only on this machine and only while the proxy runs. */
|
|
119
|
+
urls(instanceId) {
|
|
120
|
+
const prefix = `http://127.0.0.1:${this.#port}/${this.#cap}/${instanceId}`;
|
|
121
|
+
if (this.#instances.has(instanceId))
|
|
122
|
+
return { lane: 'pooled', mcp: `${prefix}/mcp`, invoke: `${prefix}/v1/invoke` };
|
|
123
|
+
if (this.#isolated.has(instanceId))
|
|
124
|
+
return { lane: 'isolated', mcp: `${prefix}/mcp`, operations: `${prefix}/operations`, requests: `${prefix}/requests` };
|
|
125
|
+
throw new CliError('native_tool_access_unavailable', 'That tool is not served here.');
|
|
126
|
+
}
|
|
127
|
+
/** Stop serving one instance (it ended at root). */
|
|
128
|
+
end(instanceId, code) {
|
|
129
|
+
if ((!this.#instances.has(instanceId) && !this.#isolated.has(instanceId)) || this.#ended.has(instanceId))
|
|
130
|
+
return;
|
|
131
|
+
this.#ended.set(instanceId, code);
|
|
132
|
+
this.#tokens.delete(instanceId);
|
|
133
|
+
clearTimeout(this.#expiries.get(instanceId));
|
|
134
|
+
this.#expiries.delete(instanceId);
|
|
135
|
+
this.#options.onEnded(instanceId, code);
|
|
136
|
+
}
|
|
137
|
+
/** An isolated entry stops being served when its connection expires; nothing renews it. */
|
|
138
|
+
#expireAt(grant) {
|
|
139
|
+
const left = Date.parse(grant.expires_at) - Date.now();
|
|
140
|
+
if (!(left > 0)) {
|
|
141
|
+
this.#ended.set(grant.instance_id, STANDALONE_ACCESS_ERRORS.connectionEnded);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
this.#expiries.delete(grant.instance_id);
|
|
146
|
+
if (Date.parse(grant.expires_at) > Date.now())
|
|
147
|
+
this.#expireAt(grant);
|
|
148
|
+
else
|
|
149
|
+
this.end(grant.instance_id, STANDALONE_ACCESS_ERRORS.connectionEnded);
|
|
150
|
+
}, Math.min(left, TIMER_MAX_MS));
|
|
151
|
+
timer.unref();
|
|
152
|
+
this.#expiries.set(grant.instance_id, timer);
|
|
153
|
+
}
|
|
154
|
+
/** Root refused a call with this grant (401): end the entry only when root confirms the grant or instance is done. */
|
|
155
|
+
async #confirmIsolated(grant) {
|
|
156
|
+
if (this.#confirming.has(grant.instance_id) || this.#ended.has(grant.instance_id) || this.#closed
|
|
157
|
+
|| Date.now() - (this.#confirmedAt.get(grant.instance_id) ?? 0) < CONFIRM_INTERVAL_MS)
|
|
158
|
+
return;
|
|
159
|
+
this.#confirming.add(grant.instance_id);
|
|
160
|
+
this.#confirmedAt.set(grant.instance_id, Date.now());
|
|
161
|
+
try {
|
|
162
|
+
const state = await isolatedGrantState(this.#options.scope, grant, AbortSignal.any([this.#controller.signal, AbortSignal.timeout(CLEANUP_MS)]));
|
|
163
|
+
if (state === 'connection_ended')
|
|
164
|
+
this.end(grant.instance_id, STANDALONE_ACCESS_ERRORS.connectionEnded);
|
|
165
|
+
else if (state === 'instance_ended')
|
|
166
|
+
this.end(grant.instance_id, STANDALONE_ACCESS_ERRORS.ended);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
this.#confirming.delete(grant.instance_id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Stop accepting calls, abort in-flight ones and forget every credential. Safe to call more than once. */
|
|
173
|
+
async close() {
|
|
174
|
+
if (this.#closed)
|
|
175
|
+
return;
|
|
176
|
+
this.#closed = true;
|
|
177
|
+
this.#controller.abort();
|
|
178
|
+
this.#tokens.clear();
|
|
179
|
+
for (const timer of this.#expiries.values())
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
this.#expiries.clear();
|
|
182
|
+
await new Promise((resolve) => { this.#server.close(() => resolve()); this.#server.closeAllConnections(); });
|
|
183
|
+
}
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
// Inbound (the agent on this machine).
|
|
186
|
+
async #handle(request, response) {
|
|
187
|
+
try {
|
|
188
|
+
if (this.#closed)
|
|
189
|
+
return refuse(response, 503, 'tool_access_closed');
|
|
190
|
+
if (request.headers.origin !== undefined)
|
|
191
|
+
return refuse(response, 403, 'origin_denied');
|
|
192
|
+
if (request.headers.host !== `127.0.0.1:${this.#port}`)
|
|
193
|
+
return refuse(response, 403, 'host_denied');
|
|
194
|
+
const match = ROUTE.exec(request.url ?? '');
|
|
195
|
+
if (!match || !sameCap(match[1], this.#cap))
|
|
196
|
+
return refuse(response, 404, 'not_found');
|
|
197
|
+
const isolated = this.#isolated.get(match[2]);
|
|
198
|
+
if (isolated) {
|
|
199
|
+
if (!(Date.parse(isolated.expires_at) > Date.now()))
|
|
200
|
+
this.end(isolated.instance_id, STANDALONE_ACCESS_ERRORS.connectionEnded);
|
|
201
|
+
const ended = this.#ended.get(isolated.instance_id);
|
|
202
|
+
if (ended)
|
|
203
|
+
return refuse(response, 410, ended);
|
|
204
|
+
return await this.#isolatedCall(isolated, match, request, response);
|
|
205
|
+
}
|
|
206
|
+
const instance = match[3] === 'mcp' || match[3] === 'v1/invoke' ? this.#instances.get(match[2]) : undefined;
|
|
207
|
+
if (!instance)
|
|
208
|
+
return refuse(response, 404, 'not_found');
|
|
209
|
+
if (request.method !== 'POST')
|
|
210
|
+
return refuse(response, 405, 'method_not_supported');
|
|
211
|
+
const ended = this.#ended.get(instance.id);
|
|
212
|
+
if (ended)
|
|
213
|
+
return refuse(response, 410, ended);
|
|
214
|
+
const declared = Number(request.headers['content-length']);
|
|
215
|
+
if (Number.isFinite(declared) && declared > BODY_MAX_BYTES)
|
|
216
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
217
|
+
const body = await readBody(request);
|
|
218
|
+
if (body === null)
|
|
219
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
220
|
+
let message;
|
|
221
|
+
try {
|
|
222
|
+
message = JSON.parse(body.toString('utf8'));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return refuse(response, 400, 'request_invalid');
|
|
226
|
+
}
|
|
227
|
+
if (match[3] === 'mcp')
|
|
228
|
+
await this.#mcp(instance, message, response);
|
|
229
|
+
else
|
|
230
|
+
await this.#invoke(instance, message, response);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
if (!response.headersSent)
|
|
234
|
+
refuse(response, 502, 'tool_unavailable');
|
|
235
|
+
else
|
|
236
|
+
response.destroy();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** One call for an isolated instance, passed through on its grant (never retried; see the module note). */
|
|
240
|
+
async #isolatedCall(grant, match, request, response) {
|
|
241
|
+
const route = match[3];
|
|
242
|
+
const declared = Number(request.headers['content-length']);
|
|
243
|
+
if (Number.isFinite(declared) && declared > ISOLATED_BODY_MAX_BYTES)
|
|
244
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
245
|
+
const forward = async (url, method, headers, body, passBack, extra) => {
|
|
246
|
+
const sent = await forwardIsolated({ url, token: grant.token, method, headers, body, passBack, extra, response, signal: this.#controller.signal });
|
|
247
|
+
// Root's 401 is passed back as it is; whether the entry ends is decided by reading the grant, never by the 401 alone.
|
|
248
|
+
if (sent === 401)
|
|
249
|
+
void this.#confirmIsolated(grant).catch(() => undefined);
|
|
250
|
+
return sent;
|
|
251
|
+
};
|
|
252
|
+
if (route === 'mcp') {
|
|
253
|
+
// Root's MCP endpoint offers no server-initiated stream (GET), as it says itself.
|
|
254
|
+
if (request.method === 'GET') {
|
|
255
|
+
response.writeHead(405, { allow: 'POST, DELETE', 'cache-control': 'no-store', 'content-length': '0' });
|
|
256
|
+
response.end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (request.method !== 'POST' && request.method !== 'DELETE')
|
|
260
|
+
return refuse(response, 405, 'method_not_supported');
|
|
261
|
+
const method = request.method === 'DELETE' ? 'DELETE' : 'POST';
|
|
262
|
+
const body = method === 'POST' ? await readBounded(request, ISOLATED_BODY_MAX_BYTES) : null;
|
|
263
|
+
if (method === 'POST' && body === null)
|
|
264
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
265
|
+
const sent = await forward(grant.mcp_url, method, pickHeaders(request, MCP_REQUEST_HEADERS), body, MCP_RESPONSE_HEADERS);
|
|
266
|
+
if (sent !== 'unanswered')
|
|
267
|
+
return;
|
|
268
|
+
let rpc = null;
|
|
269
|
+
try {
|
|
270
|
+
rpc = body ? record(JSON.parse(body.toString('utf8'))) : null;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
rpc = null;
|
|
274
|
+
}
|
|
275
|
+
const id = rpc && (typeof rpc.id === 'string' || Number.isSafeInteger(rpc.id)) ? rpc.id : undefined;
|
|
276
|
+
if (id === undefined)
|
|
277
|
+
return refuse(response, 502, 'tool_unavailable');
|
|
278
|
+
return rpcReply(response, id, { error: { code: -32603, message: rpc?.method === 'tools/call' ? ISOLATED_RPC_UNANSWERED_TEXT : 'The tool did not answer.' } });
|
|
279
|
+
}
|
|
280
|
+
if (route.startsWith('requests/')) {
|
|
281
|
+
if (request.method !== 'GET')
|
|
282
|
+
return refuse(response, 405, 'method_not_supported');
|
|
283
|
+
const sent = await forward(`${grant.requests_url}/${match[6]}`, 'GET', {}, null, OPERATION_RESPONSE_HEADERS);
|
|
284
|
+
if (sent === 'unanswered')
|
|
285
|
+
refuse(response, 502, 'tool_unavailable');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// operations/{service_key}/{operation_name}
|
|
289
|
+
if (request.method !== 'POST')
|
|
290
|
+
return refuse(response, 405, 'method_not_supported');
|
|
291
|
+
if (!grant.operations.some((one) => one.service_key === match[4] && one.operation_name === match[5])) {
|
|
292
|
+
return json(response, 403, { error: 'operation_not_connected', message: 'This operation is not part of this connection (read-only unless writes were allowed). Nothing was sent.' }, true);
|
|
293
|
+
}
|
|
294
|
+
const given = request.headers['idempotency-key'];
|
|
295
|
+
if (given !== undefined && (typeof given !== 'string' || !UUID.test(given))) {
|
|
296
|
+
return json(response, 400, { error: 'request_invalid', message: 'Use a UUID as the Idempotency-Key, and reuse it to read or resend the same call.' });
|
|
297
|
+
}
|
|
298
|
+
const key = typeof given === 'string' ? given.toLowerCase() : randomUUID();
|
|
299
|
+
const body = await readBounded(request, ISOLATED_BODY_MAX_BYTES);
|
|
300
|
+
if (body === null)
|
|
301
|
+
return refuse(response, 413, 'request_too_large', undefined, true);
|
|
302
|
+
const sent = await forward(`${grant.operations_url}/${match[4]}/${match[5]}`, 'POST', { ...pickHeaders(request, ['content-type']), 'idempotency-key': key }, body, OPERATION_RESPONSE_HEADERS, { 'idempotency-key': key });
|
|
303
|
+
if (sent === 'unanswered')
|
|
304
|
+
json(response, 502, { error: 'tool_unavailable', idempotency_key: key, message: ISOLATED_UNANSWERED_TEXT });
|
|
305
|
+
}
|
|
306
|
+
async #invoke(instance, message, response) {
|
|
307
|
+
const call = record(message);
|
|
308
|
+
if (!call || !Object.keys(call).every((key) => INVOKE_KEYS.has(key)) || typeof call.operation !== 'string'
|
|
309
|
+
|| (call.arguments !== undefined && !record(call.arguments))
|
|
310
|
+
|| (call.service_key !== undefined && call.service_key !== instance.service_key)
|
|
311
|
+
|| (call.idempotency_key !== undefined && !validKey(call.idempotency_key)))
|
|
312
|
+
return refuse(response, 400, 'request_invalid');
|
|
313
|
+
const tools = await this.#idempotencyArgument(instance);
|
|
314
|
+
if (tools === undefined) {
|
|
315
|
+
const ended = this.#ended.get(instance.id);
|
|
316
|
+
if (ended)
|
|
317
|
+
return refuse(response, 410, ended);
|
|
318
|
+
response.setHeader('retry-after', '1');
|
|
319
|
+
return json(response, 503, { error: 'tool_unavailable', message: 'The tool list could not be read, so nothing was sent. Try again.' });
|
|
320
|
+
}
|
|
321
|
+
// The tool host lists every granted operation: anything else would only be refused, so it is never sent.
|
|
322
|
+
if (!tools.has(call.operation))
|
|
323
|
+
return json(response, 403, { error: 'operation_not_connected', message: this.#notConnected() });
|
|
324
|
+
// The tool host refuses a write whose declared idempotency argument and idempotency_key differ, so both carry one key.
|
|
325
|
+
const argument = tools.get(call.operation) ?? null;
|
|
326
|
+
const keyed = keyedCall(record(call.arguments) ?? {}, typeof call.idempotency_key === 'string' ? { [IDEMPOTENCY_META]: call.idempotency_key } : {}, argument);
|
|
327
|
+
if (!keyed) {
|
|
328
|
+
return json(response, 400, { error: 'idempotency_key_conflict',
|
|
329
|
+
message: `The ${argument} argument and idempotency_key name different keys, so nothing was sent. Use one key.` });
|
|
330
|
+
}
|
|
331
|
+
// One key per inbound call, reused by the retry: a committed write replays instead of repeating.
|
|
332
|
+
const key = keyed.key;
|
|
333
|
+
const upstream = await this.#forward(instance, 'invoke', { service_key: instance.service_key, operation: call.operation,
|
|
334
|
+
arguments: keyed.arguments, idempotency_key: key });
|
|
335
|
+
if (typeof upstream === 'string') {
|
|
336
|
+
return json(response, this.#ended.has(instance.id) ? 410 : 502, { error: upstream, idempotency_key: key, message: UNANSWERED_TEXT });
|
|
337
|
+
}
|
|
338
|
+
if (upstream.status === 409 && gatewayCode(upstream.body) === 'tool_access_unavailable') {
|
|
339
|
+
return json(response, 409, { error: 'tool_access_unavailable', idempotency_key: key, message: UNANSWERED_TEXT });
|
|
340
|
+
}
|
|
341
|
+
response.writeHead(upstream.status, { 'content-type': typeof upstream.headers['content-type'] === 'string' ? upstream.headers['content-type'] : 'application/octet-stream',
|
|
342
|
+
'content-length': String(upstream.body.length), 'cache-control': 'no-store' });
|
|
343
|
+
response.end(upstream.body);
|
|
344
|
+
}
|
|
345
|
+
async #mcp(instance, message, response) {
|
|
346
|
+
const rpc = record(message);
|
|
347
|
+
if (!rpc || rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string' || !Object.keys(rpc).every((key) => RPC_KEYS.has(key))
|
|
348
|
+
|| (rpc.params !== undefined && !record(rpc.params))
|
|
349
|
+
|| (rpc.id !== undefined && rpc.id !== null && typeof rpc.id !== 'string' && !Number.isSafeInteger(rpc.id))) {
|
|
350
|
+
return refuse(response, 400, 'request_invalid');
|
|
351
|
+
}
|
|
352
|
+
// Notifications need no answer from the tool host.
|
|
353
|
+
if (rpc.id === undefined) {
|
|
354
|
+
response.writeHead(202, { 'cache-control': 'no-store', 'content-length': '0' });
|
|
355
|
+
response.end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const id = rpc.id;
|
|
359
|
+
if (id === null || (typeof id === 'string' && id.length > 200))
|
|
360
|
+
return rpcReply(response, id, { error: { code: -32600, message: 'Use a request id of up to 200 characters.' } });
|
|
361
|
+
if (!FORWARDED.has(rpc.method))
|
|
362
|
+
return rpcReply(response, id, { error: { code: -32601, message: 'Method not found' } });
|
|
363
|
+
const params = record(rpc.params) ?? {};
|
|
364
|
+
let forwarded;
|
|
365
|
+
let key = null;
|
|
366
|
+
if (rpc.method === 'tools/call') {
|
|
367
|
+
if (typeof params.name !== 'string' || (params.arguments !== undefined && !record(params.arguments))
|
|
368
|
+
|| (params._meta !== undefined && !record(params._meta))) {
|
|
369
|
+
return rpcReply(response, id, { error: { code: -32602, message: 'Use a tool name and an arguments object.' } });
|
|
370
|
+
}
|
|
371
|
+
// The tool's declared idempotency argument (root's tools/list metadata) carries the same key as _meta.
|
|
372
|
+
const argument = await this.#idempotencyArgument(instance);
|
|
373
|
+
if (argument === undefined) {
|
|
374
|
+
return rpcReply(response, id, { error: { code: -32603, message: 'The tool list could not be read, so nothing was sent. Try again.' } });
|
|
375
|
+
}
|
|
376
|
+
// The tool host lists every granted operation: anything else would only be refused, so it is never sent.
|
|
377
|
+
if (!argument.has(params.name))
|
|
378
|
+
return rpcReply(response, id, { error: { code: -32602, message: this.#notConnected() } });
|
|
379
|
+
const meta = record(params._meta) ?? {};
|
|
380
|
+
const keyed = keyedCall(record(params.arguments) ?? {}, meta, argument.get(params.name) ?? null);
|
|
381
|
+
if (!keyed) {
|
|
382
|
+
return rpcReply(response, id, { error: { code: -32602,
|
|
383
|
+
message: `The ${argument.get(params.name)} argument and _meta ${IDEMPOTENCY_META} name different keys, so nothing was sent. Use one key.` } });
|
|
384
|
+
}
|
|
385
|
+
key = keyed.key;
|
|
386
|
+
forwarded = { jsonrpc: '2.0', id, method: 'tools/call',
|
|
387
|
+
params: { name: params.name, arguments: keyed.arguments, _meta: { ...meta, [IDEMPOTENCY_META]: key } } };
|
|
388
|
+
}
|
|
389
|
+
else if (rpc.method === 'tools/list') {
|
|
390
|
+
// The tool host lists every granted tool at once and takes no cursor.
|
|
391
|
+
forwarded = { jsonrpc: '2.0', id, method: 'tools/list', params: record(params._meta) ? { _meta: params._meta } : {} };
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
forwarded = { jsonrpc: '2.0', id, method: rpc.method, params };
|
|
395
|
+
}
|
|
396
|
+
const upstream = await this.#forward(instance, 'mcp', forwarded);
|
|
397
|
+
if (typeof upstream === 'string' || upstream.status !== 200) {
|
|
398
|
+
const ended = this.#ended.has(instance.id);
|
|
399
|
+
return rpcReply(response, id, { error: { code: -32603, message: ended
|
|
400
|
+
? 'This tool is no longer reachable through this connection. Run firedrill tools access again.'
|
|
401
|
+
: key ? UNANSWERED_TEXT : 'The tool refused the call or did not answer.',
|
|
402
|
+
...(key ? { data: { 'firedrill/idempotency-key': key } } : {}) } });
|
|
403
|
+
}
|
|
404
|
+
let reply;
|
|
405
|
+
try {
|
|
406
|
+
reply = record(JSON.parse(upstream.body.toString('utf8')));
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
reply = null;
|
|
410
|
+
}
|
|
411
|
+
if (!reply || reply.jsonrpc !== '2.0')
|
|
412
|
+
return rpcReply(response, id, { error: { code: -32603, message: 'The tool answered malformed JSON.' } });
|
|
413
|
+
if (rpc.method === 'tools/list') {
|
|
414
|
+
const listed = listedArguments(reply.result);
|
|
415
|
+
if (listed)
|
|
416
|
+
this.#arguments.set(instance.id, listed.tools);
|
|
417
|
+
}
|
|
418
|
+
return json(response, 200, { ...reply, id });
|
|
419
|
+
}
|
|
420
|
+
/** Why a call to an operation this connection does not grant was refused before sending, with the next step for its mode. */
|
|
421
|
+
#notConnected() {
|
|
422
|
+
return this.#options.mode === 'read'
|
|
423
|
+
? 'This operation is not part of this read-only connection, so nothing was sent. Use a tool from tools/list. '
|
|
424
|
+
+ 'Writes need firedrill tools access <session-id> --allow-writes, only when the human asked for writes.'
|
|
425
|
+
: 'This operation is not part of this connection, so nothing was sent. Use a tool from tools/list.';
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Each tool's declared idempotency argument for one instance (null: none),
|
|
429
|
+
* read once from the tool host's tools/list, single-flight. Undefined when the
|
|
430
|
+
* list could not be read: the call is not sent rather than sent unshaped.
|
|
431
|
+
*/
|
|
432
|
+
async #idempotencyArgument(instance) {
|
|
433
|
+
const known = this.#arguments.get(instance.id);
|
|
434
|
+
if (known)
|
|
435
|
+
return known;
|
|
436
|
+
let pending = this.#discovering.get(instance.id);
|
|
437
|
+
if (!pending) {
|
|
438
|
+
pending = (async () => {
|
|
439
|
+
const upstream = await this.#forward(instance, 'mcp', { jsonrpc: '2.0', id: 'firedrill-discovery', method: 'tools/list', params: {} });
|
|
440
|
+
if (typeof upstream === 'string' || upstream.status !== 200)
|
|
441
|
+
return undefined;
|
|
442
|
+
let reply;
|
|
443
|
+
try {
|
|
444
|
+
reply = record(JSON.parse(upstream.body.toString('utf8')));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
reply = null;
|
|
448
|
+
}
|
|
449
|
+
const listed = reply?.jsonrpc === '2.0' ? listedArguments(reply.result) : null;
|
|
450
|
+
if (!listed)
|
|
451
|
+
return undefined;
|
|
452
|
+
this.#arguments.set(instance.id, listed.tools);
|
|
453
|
+
return listed.tools;
|
|
454
|
+
})().finally(() => this.#discovering.delete(instance.id));
|
|
455
|
+
this.#discovering.set(instance.id, pending);
|
|
456
|
+
}
|
|
457
|
+
return pending;
|
|
458
|
+
}
|
|
459
|
+
// -------------------------------------------------------------------------
|
|
460
|
+
// Outbound (the instance's tool host).
|
|
461
|
+
/** One call with a credential, retried at most once with a fresh credential and the same body. */
|
|
462
|
+
async #forward(instance, kind, body) {
|
|
463
|
+
const bytes = Buffer.from(JSON.stringify(body), 'utf8');
|
|
464
|
+
let fresh = false;
|
|
465
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
466
|
+
let credential;
|
|
467
|
+
try {
|
|
468
|
+
credential = await this.#credential(instance, fresh);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return this.#ended.get(instance.id) ?? 'tool_access_unavailable';
|
|
472
|
+
}
|
|
473
|
+
let upstream;
|
|
474
|
+
try {
|
|
475
|
+
upstream = await post(kind === 'invoke' ? credential.invoke_url : credential.mcp_url, credential.token, kind === 'mcp', bytes, this.#controller.signal);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// No complete answer: once more with a fresh credential; the same key makes a committed write replay.
|
|
479
|
+
if (attempt === 0 && !this.#closed) {
|
|
480
|
+
fresh = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
return 'tool_unavailable';
|
|
484
|
+
}
|
|
485
|
+
// The tool host answers every refusal, an expired credential included, with this one code.
|
|
486
|
+
if (upstream.status === 409 && gatewayCode(upstream.body) === 'tool_access_unavailable' && attempt === 0 && !this.#closed) {
|
|
487
|
+
fresh = true;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
return upstream;
|
|
491
|
+
}
|
|
492
|
+
return 'tool_unavailable';
|
|
493
|
+
}
|
|
494
|
+
/** A usable credential: cached while valid (a fresh one is used at least once), else fetched single-flight. */
|
|
495
|
+
async #credential(instance, fresh) {
|
|
496
|
+
const cached = this.#tokens.get(instance.id);
|
|
497
|
+
if (cached && !fresh && (performance.now() < cached.validUntil || !cached.used)) {
|
|
498
|
+
cached.used = true;
|
|
499
|
+
return cached.credential;
|
|
500
|
+
}
|
|
501
|
+
let pending = this.#fetching.get(instance.id);
|
|
502
|
+
if (!pending) {
|
|
503
|
+
pending = this.#fetch(instance).finally(() => this.#fetching.delete(instance.id));
|
|
504
|
+
this.#fetching.set(instance.id, pending);
|
|
505
|
+
}
|
|
506
|
+
const credential = await pending;
|
|
507
|
+
const entry = this.#tokens.get(instance.id);
|
|
508
|
+
if (entry?.credential === credential)
|
|
509
|
+
entry.used = true;
|
|
510
|
+
return credential;
|
|
511
|
+
}
|
|
512
|
+
async #fetch(instance) {
|
|
513
|
+
const deadline = performance.now() + TOKEN_WAIT_MS;
|
|
514
|
+
let checked = false;
|
|
515
|
+
for (let attempt = 0;; attempt++) {
|
|
516
|
+
if (this.#closed)
|
|
517
|
+
throw new CliError('tool_access_closed', 'Tool access stopped.');
|
|
518
|
+
const ended = this.#ended.get(instance.id);
|
|
519
|
+
if (ended)
|
|
520
|
+
throw new CliError(ended, 'This tool is no longer served here.');
|
|
521
|
+
let credential;
|
|
522
|
+
try {
|
|
523
|
+
credential = await standaloneCredential(this.#options.scope, { instance, mode: this.#options.mode, signal: this.#controller.signal });
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
const status = error instanceof CliError ? error.httpStatus : null;
|
|
527
|
+
const code = errorCode(error);
|
|
528
|
+
// The terminal is signed out, its workspace changed, or no tool host is configured: nothing here can succeed.
|
|
529
|
+
if (status === 401 || code === 'native_workspace_changed' || (status === 503 && code === 'tool_access_unavailable')) {
|
|
530
|
+
this.#options.onFatal(error);
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
// Root answers a stopped or expired instance with 410 tool_instance_ended: end access at once.
|
|
534
|
+
if (status === 410 && code === 'tool_instance_ended') {
|
|
535
|
+
this.end(instance.id, STANDALONE_ACCESS_ERRORS.ended);
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
// An older root answered it with a generic 500, so read the instance once before waiting.
|
|
539
|
+
if (status === 500 && !checked && !this.#closed) {
|
|
540
|
+
checked = true;
|
|
541
|
+
if (await this.#gone(instance)) {
|
|
542
|
+
this.end(instance.id, STANDALONE_ACCESS_ERRORS.ended);
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (isTransient(error) && performance.now() < deadline && !this.#closed) {
|
|
547
|
+
if (!await sleep(Math.min(4_000, 500 * 2 ** attempt), this.#controller.signal))
|
|
548
|
+
throw error;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
// Answered: the instance was reset to a new generation (409 tool_access_changed), root refused
|
|
552
|
+
// access to it (409 tool_access_unavailable), or this mode is not allowed.
|
|
553
|
+
if (status !== null && status >= 400 && status < 500)
|
|
554
|
+
this.end(instance.id, code === 'tool_access_changed' ? STANDALONE_ACCESS_ERRORS.ended : code ?? STANDALONE_ACCESS_ERRORS.ended);
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
const origin = new URL(credential.invoke_url).origin;
|
|
558
|
+
const pinned = this.#origins.get(instance.id);
|
|
559
|
+
if (pinned && pinned !== origin) {
|
|
560
|
+
this.end(instance.id, STANDALONE_ACCESS_ERRORS.changed);
|
|
561
|
+
throw new CliError(STANDALONE_ACCESS_ERRORS.changed, 'The tool host changed while connected.');
|
|
562
|
+
}
|
|
563
|
+
this.#origins.set(instance.id, origin);
|
|
564
|
+
// Monotonic window from receipt time, so a wall-clock jump never extends a credential's use.
|
|
565
|
+
const lifetime = Math.min(30_000, credential.expires_at * 1_000 - Date.now()) - TOKEN_MARGIN_MS;
|
|
566
|
+
this.#tokens.set(instance.id, { credential, validUntil: performance.now() + lifetime, used: false });
|
|
567
|
+
return credential;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Whether root says the instance can no longer be served here: not active, reset to a new generation, or gone. */
|
|
571
|
+
async #gone(instance) {
|
|
572
|
+
try {
|
|
573
|
+
const current = await getStandaloneInstance(this.#options.scope, { instanceId: instance.id, signal: this.#controller.signal });
|
|
574
|
+
return current.state !== STANDALONE_ACTIVE || current.generation !== instance.generation;
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
return error instanceof CliError && error.httpStatus === 404;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|