firedrill-run 2.2.4 → 3.0.0
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 +178 -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 +465 -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 +1622 -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 +61 -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,615 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { api } from './auth.js';
|
|
7
|
+
import { localRunnerLookupMatches, localRunnersMatch } from '../../contracts/local-runner-contracts.js';
|
|
8
|
+
import { privateDirectory } from './credentials.js';
|
|
9
|
+
import { withBootstrapProcessLock } from './bootstrap-process-lock.js';
|
|
10
|
+
import { API_ORIGIN, CliError } from './http.js';
|
|
11
|
+
import { selectOne } from './prompts.js';
|
|
12
|
+
import { selectedWorkspace } from './world-access.js';
|
|
13
|
+
import { identifier } from './world-contracts.js';
|
|
14
|
+
function canonical(value) {
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return '[' + value.map(canonical).join(',') + ']';
|
|
17
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
18
|
+
const object = value;
|
|
19
|
+
return '{' + Object.keys(object).sort().map(key => canonical(key) + ':' + canonical(object[key])).join(',') + '}';
|
|
20
|
+
}
|
|
21
|
+
const encoded = JSON.stringify(value);
|
|
22
|
+
if (encoded === undefined)
|
|
23
|
+
throw new CliError('runner_journal_invalid', 'The runner request contains an unsupported value.');
|
|
24
|
+
// Root signs and digests Python json.dumps(ensure_ascii=True): every code unit outside space..tilde is \u-escaped.
|
|
25
|
+
return encoded.replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
26
|
+
}
|
|
27
|
+
const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex');
|
|
28
|
+
const pathFor = (scope, key) => `/api/workspaces/${identifier(scope.workspace)}/local-runners/${identifier(key)}`;
|
|
29
|
+
const auth = (scope, signal) => ({ actor: scope.owner, workspace: scope.workspace, signal });
|
|
30
|
+
async function runnerReceipt(scope, key, options, suffix = '') {
|
|
31
|
+
const value = await api(pathFor(scope, key) + suffix, options);
|
|
32
|
+
if (!localRunnerLookupMatches(value, scope.owner, scope.workspace, key))
|
|
33
|
+
throw new CliError('runner_receipt_invalid', 'The runner response does not match this account, workspace and original device. No local work was started.');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
const fileFor = async (scope, key) => join(await privateDirectory(), `runner-${identifier(scope.owner)}-${identifier(scope.workspace)}-${identifier(key)}.json`);
|
|
37
|
+
/**
|
|
38
|
+
* Private keys of session-only runners enrolled by this process. Runners are
|
|
39
|
+
* bound to one native session, and a session-only sign-in keeps nothing in the
|
|
40
|
+
* OS credential store, so its runner key lives here and is gone when the process
|
|
41
|
+
* exits. It is never written to disk, logged or sent; only its public key is
|
|
42
|
+
* enrolled. The runner then stops being usable with its session.
|
|
43
|
+
*/
|
|
44
|
+
const MEMORY_KEYS = new Map();
|
|
45
|
+
async function store(scope, key) {
|
|
46
|
+
try {
|
|
47
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
48
|
+
return new Entry('run.firedrill.simulation.runner-key.v1', `${API_ORIGIN}/${identifier(scope.owner)}/${identifier(scope.workspace)}/${identifier(key)}`, process.platform === 'linux' ? { linux: { store: 'secret-service' } } : undefined);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new CliError('runner_key_store_unavailable', 'Unlock your OS credential store. Runner keys are never saved in project files.');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function persist(value) {
|
|
55
|
+
const path = await fileFor(value, value.key);
|
|
56
|
+
const temporary = path + '.' + randomUUID() + '.tmp';
|
|
57
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
58
|
+
try {
|
|
59
|
+
await file.writeFile(JSON.stringify(value));
|
|
60
|
+
await file.sync();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await rename(temporary, path);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await unlink(temporary).catch(() => undefined);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function saved(scope, key) {
|
|
74
|
+
const path = await fileFor(scope, key);
|
|
75
|
+
const stat = await lstat(path);
|
|
76
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384 || (stat.mode & 0o077) !== 0
|
|
77
|
+
|| process.getuid && stat.uid !== process.getuid())
|
|
78
|
+
throw new CliError('runner_journal_unsafe', 'Runner metadata must be private and owned by this OS user.');
|
|
79
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
80
|
+
try {
|
|
81
|
+
const actual = await file.stat();
|
|
82
|
+
if (actual.ino !== stat.ino || actual.dev !== stat.dev || actual.size !== stat.size)
|
|
83
|
+
throw new Error();
|
|
84
|
+
const value = JSON.parse(await file.readFile('utf8'));
|
|
85
|
+
if (value.schema !== 'firedrill.runner-identity.v1' || value.owner !== scope.owner || value.workspace !== scope.workspace
|
|
86
|
+
|| value.key !== identifier(key) || value.enrollment?.schema !== 'firedrill.local-runner-enroll.v1'
|
|
87
|
+
|| value.enrollment.device_key !== value.key || value.enrollment.actor_id !== scope.owner || value.enrollment.workspace_id !== scope.workspace
|
|
88
|
+
|| !['enrollment_dispatched', 'enrolled', 'revoked'].includes(value.stage))
|
|
89
|
+
throw new Error();
|
|
90
|
+
identifier(value.enrollment.native_session_id);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new CliError('runner_journal_invalid', 'The runner journal could not be read safely. It was not replaced.');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await file.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function lock(scope, key, signal, work) {
|
|
101
|
+
const directory = join(await privateDirectory(), `runner-lock-${identifier(scope.owner)}-${identifier(scope.workspace)}-${identifier(key)}`);
|
|
102
|
+
return withBootstrapProcessLock(directory, signal, work);
|
|
103
|
+
}
|
|
104
|
+
async function proof(value, payload) {
|
|
105
|
+
try {
|
|
106
|
+
const encoded = MEMORY_KEYS.get(value.key) ?? (await store(value, value.key)).getPassword();
|
|
107
|
+
if (!encoded || encoded.length > 2048)
|
|
108
|
+
throw new Error();
|
|
109
|
+
const key = createPrivateKey({ key: Buffer.from(encoded, 'base64'), format: 'der', type: 'pkcs8' });
|
|
110
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
111
|
+
throw new Error();
|
|
112
|
+
const publicKey = createPublicKey(key).export({ format: 'jwk' });
|
|
113
|
+
if (!publicKey.x || Buffer.from(publicKey.x, 'base64url').toString('base64') !== value.enrollment.public_key)
|
|
114
|
+
throw new Error();
|
|
115
|
+
return { payload, signature: sign(null, Buffer.from('firedrill.local-runner-proof.v1\n' + canonical(payload)), key).toString('base64') };
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new CliError('runner_key_unavailable', 'This runner key could not be read from the OS credential store. It was not regenerated.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function signRunnerRequest(scope, key, payload, signal) {
|
|
122
|
+
return (await runnerRequestSigner(scope, key, signal))(payload);
|
|
123
|
+
}
|
|
124
|
+
/** Closing facts only, under the same process lock as the live companion. */
|
|
125
|
+
export async function withHistoricalRunCleanup(scope, key, signal, work, purpose = 'run') {
|
|
126
|
+
key = identifier(key);
|
|
127
|
+
return lock(scope, key, signal, async (active) => {
|
|
128
|
+
const value = await saved(scope, key);
|
|
129
|
+
const session = await api('/api/auth/native/session', auth(scope, active));
|
|
130
|
+
if (session.workspace?.can_write !== true || value.stage === 'revoked')
|
|
131
|
+
throw new CliError('runner_cleanup_unavailable', 'Sign in with current write access to this workspace. A revoked device cannot report new cleanup facts.');
|
|
132
|
+
const retained = await runnerReceipt(scope, key, auth(scope, active));
|
|
133
|
+
if (retained.state === 'revoked' || retained.state === 'not_retained')
|
|
134
|
+
throw new CliError('runner_cleanup_unavailable', 'The original non-revoked device is required. No new identity was enrolled.');
|
|
135
|
+
matches(value, retained);
|
|
136
|
+
const deviceId = deviceIdOf(retained);
|
|
137
|
+
if (!deviceId)
|
|
138
|
+
throw new CliError('runner_receipt_invalid', 'The original device identifier is unavailable.');
|
|
139
|
+
return work({ deviceId, signal: active, sign: async (payload) => {
|
|
140
|
+
active.throwIfAborted();
|
|
141
|
+
const allowed = purpose === 'run'
|
|
142
|
+
? payload.schema === 'firedrill.native-local-run-lifecycle.v1' && payload.device_id === deviceId
|
|
143
|
+
&& ['observe', 'close'].includes(String(payload.action))
|
|
144
|
+
: payload.schema === 'firedrill.native-execution-cleanup.v1';
|
|
145
|
+
if (!allowed)
|
|
146
|
+
throw new CliError('runner_cleanup_only', 'This signer accepts only the original run closing evidence.');
|
|
147
|
+
return proof(value, payload);
|
|
148
|
+
} });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Bounded caller-owned transfer session. Server checks current authority for
|
|
152
|
+
* EVERY signed operation; this removes repeated preflight GETs, not revocation.
|
|
153
|
+
* Only public enrollment metadata is captured, never a personal access token.
|
|
154
|
+
*/
|
|
155
|
+
export async function runnerRequestSigner(scope, key, signal) {
|
|
156
|
+
const value = await saved(scope, identifier(key));
|
|
157
|
+
const session = await api('/api/auth/native/session', auth(scope, signal));
|
|
158
|
+
if (session.id !== value.enrollment.native_session_id || value.stage === 'revoked') {
|
|
159
|
+
throw new CliError('runner_scope_changed', 'This runner belongs to another terminal session or was revoked.');
|
|
160
|
+
}
|
|
161
|
+
// Signing another scoped approval must not rewrite the live presence
|
|
162
|
+
// process's pending-pulse journal from this independently read snapshot.
|
|
163
|
+
const receipt = await runnerReceipt(scope, key, auth(scope, signal));
|
|
164
|
+
if (['not_retained', 'revoked', 'expired', 'access_removed'].includes(receipt.state)) {
|
|
165
|
+
throw new CliError('runner_access_removed', 'This runner identity is not currently available for approval.');
|
|
166
|
+
}
|
|
167
|
+
matches(value, receipt);
|
|
168
|
+
return async (payload) => { signal.throwIfAborted(); return proof(value, payload); };
|
|
169
|
+
}
|
|
170
|
+
/** Historical evidence/closing facts only, authenticated to the current account.
|
|
171
|
+
* An expired original device session cannot heartbeat, claim or obtain runtime
|
|
172
|
+
* inputs through this signer. Revoked devices retain only original cleanup
|
|
173
|
+
* signing, not private evidence publication. Server scope remains independently
|
|
174
|
+
* authoritative for every submitted proof.
|
|
175
|
+
*/
|
|
176
|
+
export async function historicalRunnerSigner(scope, key, signal) {
|
|
177
|
+
const value = await saved(scope, identifier(key));
|
|
178
|
+
const session = await api('/api/auth/native/session', auth(scope, signal));
|
|
179
|
+
const receipt = await runnerReceipt(scope, key, auth(scope, signal));
|
|
180
|
+
matches(value, receipt);
|
|
181
|
+
const cleanupOnly = receipt.state === 'revoked' || value.stage === 'revoked' || session.workspace?.can_write !== true;
|
|
182
|
+
return { nativeSession: session.id, cleanupOnly, sign: async (payload) => {
|
|
183
|
+
signal.throwIfAborted();
|
|
184
|
+
const document = payload.document;
|
|
185
|
+
if (cleanupOnly && (payload.schema !== 'firedrill.local-completion.v1'
|
|
186
|
+
|| !['firedrill.local-container-receipt.v1', 'firedrill.local-trial-container-receipt.v1',
|
|
187
|
+
'firedrill.local-world-container-receipt.v1'].includes(String(document?.schema)))) {
|
|
188
|
+
throw new CliError('runner_cleanup_only', 'Runner execution or write access ended. Only the exact saved cleanup receipt may be signed.');
|
|
189
|
+
}
|
|
190
|
+
if (!['firedrill.local-evidence-write.v1', 'firedrill.local-trial-evidence-write.v1', 'firedrill.local-completion.v1',
|
|
191
|
+
'firedrill.local-world-closing-command-result.v1', 'firedrill.local-world-closing-file-write.v1',
|
|
192
|
+
'firedrill.local-world-final-write.v1'].includes(String(payload.schema))
|
|
193
|
+
|| payload.native_session_id !== session.id || payload.device_key !== key)
|
|
194
|
+
throw new CliError('historical_scope_invalid', 'Use the exact saved evidence or completion identity.');
|
|
195
|
+
return proof(value, payload);
|
|
196
|
+
} };
|
|
197
|
+
}
|
|
198
|
+
function matches(value, receipt) {
|
|
199
|
+
if (receipt.state === 'not_retained' || receipt.device_key !== value.key || receipt.enrollment_digest !== digest(value.enrollment)
|
|
200
|
+
|| receipt.native_session_id !== value.enrollment.native_session_id)
|
|
201
|
+
throw new CliError('runner_identity_changed', 'The saved runner receipt does not match this device identity.');
|
|
202
|
+
}
|
|
203
|
+
async function reconcile(value, signal) {
|
|
204
|
+
const receipt = await runnerReceipt(value, value.key, auth(value, signal));
|
|
205
|
+
if (receipt.state === 'not_retained')
|
|
206
|
+
return receipt;
|
|
207
|
+
if (receipt.state === 'revoked') {
|
|
208
|
+
value.stage = 'revoked';
|
|
209
|
+
value.pending = null;
|
|
210
|
+
await persist(value);
|
|
211
|
+
return receipt;
|
|
212
|
+
}
|
|
213
|
+
matches(value, receipt);
|
|
214
|
+
if (value.stage === 'enrollment_dispatched') {
|
|
215
|
+
value.stage = 'enrolled';
|
|
216
|
+
await persist(value);
|
|
217
|
+
}
|
|
218
|
+
return receipt;
|
|
219
|
+
}
|
|
220
|
+
function describe(receipt) {
|
|
221
|
+
return receipt.state === 'not_retained'
|
|
222
|
+
? `Runner ${receipt.device_key}: no retained receipt yet. A delayed enrollment may still arrive. Revoke this exact key to withdraw it.`
|
|
223
|
+
: `Runner ${receipt.device_key}: ${receipt.state.replaceAll('_', ' ')}.\nEnrollment and presence do not grant execution permission.`;
|
|
224
|
+
}
|
|
225
|
+
export async function enrollLocalRunner(scope, options, output) {
|
|
226
|
+
if (!['darwin', 'linux'].includes(process.platform) || !['arm64', 'x64'].includes(process.arch)) {
|
|
227
|
+
throw new CliError('runner_host_unsupported', 'Local runner enrollment currently supports macOS or Linux on arm64 or x64.');
|
|
228
|
+
}
|
|
229
|
+
if (!options.enroll) {
|
|
230
|
+
if (options.json || !process.stdin.isTTY || !process.stderr.isTTY)
|
|
231
|
+
throw new CliError('runner_consent_required', 'Pass --enroll-device to register this local runner identity. No execution is authorized by enrollment.', 2);
|
|
232
|
+
const approved = await selectOne('Register this local runner?', [{ value: true, label: 'Register', detail: options.ephemeral
|
|
233
|
+
? 'Share its name and OS/architecture. Its private key stays in this process\'s memory and is discarded when this run ends.'
|
|
234
|
+
: 'Share its name and OS/architecture. The private device key stays in the OS credential store.' },
|
|
235
|
+
{ value: false, label: 'Cancel' }], options.signal);
|
|
236
|
+
if (!approved)
|
|
237
|
+
throw new CliError('canceled', 'No runner was enrolled.', 130);
|
|
238
|
+
}
|
|
239
|
+
const session = await api('/api/auth/native/session', auth(scope, options.signal));
|
|
240
|
+
const label = options.name ?? 'Local runner';
|
|
241
|
+
if (!/^[\x20-\x7e]{1,80}$/.test(label) || label !== label.trim())
|
|
242
|
+
throw new CliError('runner_name_invalid', 'Use a runner name of 1 to 80 plain characters.', 2);
|
|
243
|
+
const key = randomUUID();
|
|
244
|
+
const pair = generateKeyPairSync('ed25519');
|
|
245
|
+
const publicKey = pair.publicKey.export({ format: 'jwk' });
|
|
246
|
+
if (!publicKey.x)
|
|
247
|
+
throw new CliError('runner_key_unavailable', 'A runner identity could not be created.');
|
|
248
|
+
const value = { schema: 'firedrill.runner-identity.v1', ...scope, key, stage: 'enrollment_dispatched', pending: null,
|
|
249
|
+
enrollment: { schema: 'firedrill.local-runner-enroll.v1', device_key: key, actor_id: scope.owner,
|
|
250
|
+
workspace_id: scope.workspace, native_session_id: identifier(session.id), public_key: Buffer.from(publicKey.x, 'base64url').toString('base64'),
|
|
251
|
+
label, host: { os: process.platform, arch: process.arch } } };
|
|
252
|
+
if (options.ephemeral)
|
|
253
|
+
MEMORY_KEYS.set(key, pair.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64'));
|
|
254
|
+
else
|
|
255
|
+
try {
|
|
256
|
+
const entry = await store(scope, key);
|
|
257
|
+
if (entry.getPassword() !== null)
|
|
258
|
+
throw new Error();
|
|
259
|
+
const encoded = pair.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
|
|
260
|
+
entry.setPassword(encoded);
|
|
261
|
+
if (entry.getPassword() !== encoded)
|
|
262
|
+
throw new Error();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
throw new CliError('runner_key_store_unavailable', 'The runner key was not confirmed in the OS credential store. No enrollment was sent.');
|
|
266
|
+
}
|
|
267
|
+
await persist(value);
|
|
268
|
+
output('runner_request', { device_key: key }, `Runner request ${key}. Recover with firedrill runner status ${key}.`);
|
|
269
|
+
return lock(scope, key, options.signal, async (active) => {
|
|
270
|
+
options = { ...options, signal: active };
|
|
271
|
+
try {
|
|
272
|
+
const receipt = await runnerReceipt(scope, key, { ...auth(scope, options.signal), method: 'PUT', body: await proof(value, value.enrollment) });
|
|
273
|
+
matches(value, receipt);
|
|
274
|
+
value.stage = 'enrolled';
|
|
275
|
+
await persist(value);
|
|
276
|
+
output('runner_enrolled', receipt, describe(receipt));
|
|
277
|
+
return receipt;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error instanceof CliError && ['connection_uncertain', 'response_unavailable'].includes(error.code)) {
|
|
281
|
+
const receipt = await reconcile(value, options.signal);
|
|
282
|
+
output('runner_receipt', receipt, describe(receipt));
|
|
283
|
+
return receipt;
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
export async function localRunnerSelectionPage(scope, page, signal) {
|
|
290
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000000)
|
|
291
|
+
throw new CliError('query_invalid', 'Choose a positive runner page.', 2);
|
|
292
|
+
const session = await api('/api/auth/native/session', auth(scope, signal));
|
|
293
|
+
const directory = await api(`/api/workspaces/${identifier(scope.workspace)}/local-runners?page=${page}&q=`, auth(scope, signal));
|
|
294
|
+
if (!localRunnersMatch(directory, scope.owner, scope.workspace, '', page))
|
|
295
|
+
throw new CliError('runner_receipt_invalid', 'The runner directory did not match this account, workspace and page.');
|
|
296
|
+
return { directory, candidates: directory.results.filter(row => row.native_session_id === session.id
|
|
297
|
+
&& !['revoked', 'expired', 'access_removed'].includes(row.state)
|
|
298
|
+
&& row.host.os === process.platform && row.host.arch === process.arch) };
|
|
299
|
+
}
|
|
300
|
+
export async function currentLocalRunner(scope, key, signal) {
|
|
301
|
+
let value;
|
|
302
|
+
try {
|
|
303
|
+
value = await saved(scope, identifier(key));
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error.code === 'ENOENT')
|
|
307
|
+
throw new CliError('runner_identity_not_local', 'This runner has no private identity in this OS account. Choose this device’s original runner or register this device after review. No key was regenerated.', 2);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
const session = await api('/api/auth/native/session', auth(scope, signal));
|
|
311
|
+
if (value.stage === 'revoked' || value.enrollment.native_session_id !== session.id
|
|
312
|
+
|| value.enrollment.host.os !== process.platform || value.enrollment.host.arch !== process.arch) {
|
|
313
|
+
throw new CliError('runner_scope_changed', 'Choose the original runner for this terminal session and device. No new identity was enrolled.');
|
|
314
|
+
}
|
|
315
|
+
const receipt = await runnerReceipt(scope, key, auth(scope, signal));
|
|
316
|
+
matches(value, receipt);
|
|
317
|
+
if (['revoked', 'expired', 'access_removed'].includes(receipt.state))
|
|
318
|
+
throw new CliError('runner_access_removed', 'This runner is no longer eligible for a new local permission.');
|
|
319
|
+
return receipt;
|
|
320
|
+
}
|
|
321
|
+
async function pendingPulse(value, signal) {
|
|
322
|
+
while (true) {
|
|
323
|
+
const receipt = await reconcile(value, signal);
|
|
324
|
+
if (['not_retained', 'revoked', 'expired', 'access_removed'].includes(receipt.state))
|
|
325
|
+
throw new CliError('runner_access_removed', 'Runner access ended. No local work was started.');
|
|
326
|
+
matches(value, receipt);
|
|
327
|
+
if (!value.pending)
|
|
328
|
+
return receipt;
|
|
329
|
+
const pending = value.pending.payload;
|
|
330
|
+
if (pending.schema !== 'firedrill.local-runner-presence.v1' || pending.device_key !== value.key
|
|
331
|
+
|| pending.native_session_id !== value.enrollment.native_session_id || !Number.isSafeInteger(pending.sequence)
|
|
332
|
+
|| !Number.isSafeInteger(pending.sent_at_ms))
|
|
333
|
+
throw new CliError('runner_journal_invalid', 'The pending runner record is invalid. It was not replayed.');
|
|
334
|
+
if (receipt.last_sequence === pending.sequence && receipt.last_pulse_digest === digest(pending)) {
|
|
335
|
+
value.pending = null;
|
|
336
|
+
await persist(value);
|
|
337
|
+
return receipt;
|
|
338
|
+
}
|
|
339
|
+
if (receipt.last_sequence !== pending.sequence - 1)
|
|
340
|
+
throw new CliError('runner_sequence_conflict', 'Runner presence changed in another process. Inspect the exact saved runner.');
|
|
341
|
+
const remaining = pending.sent_at_ms + 60000 - Date.parse(receipt.server_time);
|
|
342
|
+
if (!Number.isFinite(remaining))
|
|
343
|
+
throw new CliError('runner_receipt_invalid', 'The runner receipt is incomplete.');
|
|
344
|
+
if (remaining <= 0) {
|
|
345
|
+
// An older delayed record now fails its server expiry check. Only then
|
|
346
|
+
// may the same next sequence carry a new record; no uncertain pulse replay.
|
|
347
|
+
value.pending = null;
|
|
348
|
+
await persist(value);
|
|
349
|
+
return receipt;
|
|
350
|
+
}
|
|
351
|
+
await sleep(Math.min(remaining + 100, 20000), undefined, { signal });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function pulse(value, state, signal) {
|
|
355
|
+
const previous = await pendingPulse(value, signal);
|
|
356
|
+
if (!Number.isSafeInteger(previous.last_sequence) || previous.last_sequence < 0 || previous.last_sequence >= Number.MAX_SAFE_INTEGER) {
|
|
357
|
+
throw new CliError('runner_receipt_invalid', 'The runner sequence is unavailable.');
|
|
358
|
+
}
|
|
359
|
+
const sentAt = Date.parse(previous.server_time);
|
|
360
|
+
if (!Number.isSafeInteger(sentAt))
|
|
361
|
+
throw new CliError('runner_receipt_invalid', 'The runner service time is unavailable.');
|
|
362
|
+
const pending = await proof(value, { schema: 'firedrill.local-runner-presence.v1', device_key: value.key,
|
|
363
|
+
native_session_id: value.enrollment.native_session_id, sequence: previous.last_sequence + 1, sent_at_ms: sentAt, state });
|
|
364
|
+
value.pending = pending;
|
|
365
|
+
await persist(value); // Before dispatch; response loss never becomes a fresh pulse.
|
|
366
|
+
const receipt = await runnerReceipt(value, value.key, { ...auth(value, signal), method: 'POST', body: pending }, '/presence');
|
|
367
|
+
matches(value, receipt);
|
|
368
|
+
if (receipt.last_sequence !== pending.payload.sequence || receipt.last_pulse_digest !== digest(pending.payload)) {
|
|
369
|
+
throw new CliError('runner_receipt_invalid', 'The runner presence response did not match its request.');
|
|
370
|
+
}
|
|
371
|
+
value.pending = null;
|
|
372
|
+
await persist(value);
|
|
373
|
+
return receipt;
|
|
374
|
+
}
|
|
375
|
+
/** One foreground owner maintains device presence while its approved work runs.
|
|
376
|
+
* This issues no execution grant, chooses no job and never steals a live lock.
|
|
377
|
+
*/
|
|
378
|
+
export async function withRunnerPresence(scope, key, signal, output, work) {
|
|
379
|
+
key = identifier(key);
|
|
380
|
+
return lock(scope, key, signal, async (active, ownership) => {
|
|
381
|
+
signal = active;
|
|
382
|
+
const value = await saved(scope, key);
|
|
383
|
+
const session = await api('/api/auth/native/session', auth(scope, signal));
|
|
384
|
+
if (session.id !== value.enrollment.native_session_id || value.stage === 'revoked') {
|
|
385
|
+
throw new CliError('runner_scope_changed', 'This runner belongs to another terminal session or was revoked.');
|
|
386
|
+
}
|
|
387
|
+
await pulse(value, 'idle', signal);
|
|
388
|
+
const stop = new AbortController(), combined = AbortSignal.any([signal, stop.signal]);
|
|
389
|
+
let failure;
|
|
390
|
+
const keeping = (async () => {
|
|
391
|
+
while (!combined.aborted) {
|
|
392
|
+
await sleep(20000, undefined, { signal: combined });
|
|
393
|
+
await pulse(value, 'idle', combined);
|
|
394
|
+
}
|
|
395
|
+
})().catch(error => { if (!combined.aborted) {
|
|
396
|
+
failure = error;
|
|
397
|
+
stop.abort();
|
|
398
|
+
} });
|
|
399
|
+
try {
|
|
400
|
+
const result = await work(combined);
|
|
401
|
+
if (failure)
|
|
402
|
+
throw failure;
|
|
403
|
+
signal.throwIfAborted();
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
throw failure ?? error;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
stop.abort();
|
|
411
|
+
await keeping;
|
|
412
|
+
if (!value.pending) {
|
|
413
|
+
try {
|
|
414
|
+
await pulse(value, 'stopped', AbortSignal.any([AbortSignal.timeout(8000), ownership]));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
output('runner_disconnect_pending', { device_key: key }, 'Disconnect is not confirmed. The saved presence lease will expire.');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else
|
|
421
|
+
output('runner_disconnect_pending', { device_key: key }, 'A presence response is unresolved. The exact journal is retained.');
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Root's server id for this device (`LocalRunnerDevice.id`, distinct from the
|
|
427
|
+
* client `device_key`), which native executor routes are keyed on. Null when
|
|
428
|
+
* the receipt does not carry one (`not_retained`, or an older service).
|
|
429
|
+
*/
|
|
430
|
+
function deviceIdOf(receipt) {
|
|
431
|
+
const value = receipt.device_id;
|
|
432
|
+
return typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value) ? value : null;
|
|
433
|
+
}
|
|
434
|
+
/** Closing work already owned by this device is allowed during graceful stop. */
|
|
435
|
+
function shutdownProof(payload) {
|
|
436
|
+
switch (payload.schema) {
|
|
437
|
+
case 'firedrill.native-control.v1':
|
|
438
|
+
return payload.action === 'cancel' || payload.action === 'heartbeat';
|
|
439
|
+
case 'firedrill.native-target-control.v1':
|
|
440
|
+
return payload.action === 'complete' || payload.action === 'abandon';
|
|
441
|
+
case 'firedrill.native-local-run-lifecycle.v1':
|
|
442
|
+
return payload.action === 'observe' || payload.action === 'close';
|
|
443
|
+
case 'firedrill.native-execution-stop.v1':
|
|
444
|
+
case 'firedrill.native-execution-close.v1':
|
|
445
|
+
case 'firedrill.native-execution-cleanup.v1':
|
|
446
|
+
case 'firedrill.native-artifact.v1':
|
|
447
|
+
case 'firedrill.native-artifact-commit.v1':
|
|
448
|
+
return true;
|
|
449
|
+
case undefined:
|
|
450
|
+
// ObservationBatch has a numeric version rather than a schema field.
|
|
451
|
+
return payload.version === 1 && typeof payload.assignment_id === 'string'
|
|
452
|
+
&& typeof payload.attempt_id === 'string' && typeof payload.batch_id === 'string'
|
|
453
|
+
&& typeof payload.previous_ack === 'number' && Array.isArray(payload.events);
|
|
454
|
+
default:
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The enrolled runner device as a native executor (Plan20). One process owns
|
|
460
|
+
* the device's presence (process lock and pending-pulse journal unchanged) and
|
|
461
|
+
* signs its executor requests with the OS-stored device key. Pulses are
|
|
462
|
+
* serialized. `device_id` comes from the retained runner receipt; a later
|
|
463
|
+
* receipt naming a different id ends the executor.
|
|
464
|
+
*/
|
|
465
|
+
export async function withNativeExecutor(scope, key, signal, work) {
|
|
466
|
+
key = identifier(key);
|
|
467
|
+
return lock(scope, key, signal, async (active, ownership) => {
|
|
468
|
+
const value = await saved(scope, key);
|
|
469
|
+
const session = await api('/api/auth/native/session', auth(scope, active));
|
|
470
|
+
if (session.id !== value.enrollment.native_session_id || value.stage === 'revoked'
|
|
471
|
+
|| value.enrollment.host.os !== process.platform || value.enrollment.host.arch !== process.arch) {
|
|
472
|
+
throw new CliError('runner_scope_changed', 'Choose the original runner for this terminal session and device. No new identity was enrolled.');
|
|
473
|
+
}
|
|
474
|
+
const retained = await runnerReceipt(scope, key, auth(scope, active));
|
|
475
|
+
// Eligibility first: a revoked receipt carries no enrollment digest, so it would read as an identity change.
|
|
476
|
+
if (['not_retained', 'revoked', 'expired', 'access_removed'].includes(retained.state)) {
|
|
477
|
+
throw new CliError('runner_access_removed', 'This runner is no longer eligible to execute. No drill was started.');
|
|
478
|
+
}
|
|
479
|
+
matches(value, retained);
|
|
480
|
+
const deviceId = deviceIdOf(retained);
|
|
481
|
+
let chain = Promise.resolve();
|
|
482
|
+
const executor = {
|
|
483
|
+
device_key: key,
|
|
484
|
+
device_id: deviceId,
|
|
485
|
+
sign: async (payload, requestSignal) => {
|
|
486
|
+
requestSignal.throwIfAborted();
|
|
487
|
+
ownership.throwIfAborted();
|
|
488
|
+
// Ctrl+C stops new work immediately. The companion's own bounded
|
|
489
|
+
// shutdown signal still controls signatures needed to attest cleanup.
|
|
490
|
+
if (!shutdownProof(payload))
|
|
491
|
+
active.throwIfAborted();
|
|
492
|
+
return proof(value, payload);
|
|
493
|
+
},
|
|
494
|
+
pulse: (state, requestSignal) => {
|
|
495
|
+
const next = chain.catch(() => undefined).then(async () => {
|
|
496
|
+
const receipt = await pulse(value, state, AbortSignal.any([state === 'idle' ? active : ownership, requestSignal]));
|
|
497
|
+
const reported = deviceIdOf(receipt);
|
|
498
|
+
if (deviceId !== null && reported !== null && reported !== deviceId) {
|
|
499
|
+
throw new CliError('runner_scope_changed', 'The runner device identity changed. This executor stops; no new identity was enrolled.');
|
|
500
|
+
}
|
|
501
|
+
return { state: receipt.state };
|
|
502
|
+
});
|
|
503
|
+
chain = next;
|
|
504
|
+
return next;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
return work(executor);
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Pick this terminal session's runner device for native execution: the given
|
|
512
|
+
* key, else the only runner of this session and platform with a private
|
|
513
|
+
* identity on this machine. Enrollment stays an explicit, consented step.
|
|
514
|
+
*/
|
|
515
|
+
export async function nativeExecutorKey(scope, requested, signal) {
|
|
516
|
+
if (requested !== undefined)
|
|
517
|
+
return (await currentLocalRunner(scope, identifier(requested), signal)).device_key;
|
|
518
|
+
const usable = [];
|
|
519
|
+
for (let page = 1; page <= 5; page++) {
|
|
520
|
+
const { directory, candidates } = await localRunnerSelectionPage(scope, page, signal);
|
|
521
|
+
for (const candidate of candidates) {
|
|
522
|
+
try {
|
|
523
|
+
usable.push((await currentLocalRunner(scope, candidate.device_key, signal)).device_key);
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (!(error instanceof CliError) || !['runner_identity_not_local', 'runner_scope_changed', 'runner_access_removed'].includes(error.code))
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (page * directory.page_size >= directory.count)
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
if (usable.length === 1)
|
|
534
|
+
return usable[0];
|
|
535
|
+
if (usable.length === 0)
|
|
536
|
+
throw new CliError('runner_required', 'Register this device first with firedrill runner enroll, then run again. No drill was started.', 2);
|
|
537
|
+
throw new CliError('runner_ambiguous', 'Several runner devices are registered for this terminal session. Choose one with --runner <device-key>. No drill was started.', 2);
|
|
538
|
+
}
|
|
539
|
+
export async function runnerCommand(action, key, options, output) {
|
|
540
|
+
const scope = await selectedWorkspace({ workspace: options.workspace, interactive: !options.json && !!process.stdin.isTTY && !!process.stderr.isTTY,
|
|
541
|
+
openBrowser: options.openBrowser, signal: options.signal });
|
|
542
|
+
if (action === 'enroll')
|
|
543
|
+
return enrollLocalRunner(scope, options, output);
|
|
544
|
+
if (action === 'list') {
|
|
545
|
+
const page = options.page ?? '1';
|
|
546
|
+
if (!/^[1-9][0-9]{0,8}$/.test(page) || (options.search?.length ?? 0) > 200)
|
|
547
|
+
throw new CliError('query_invalid', 'Choose a positive page number and shorter search.', 2);
|
|
548
|
+
const result = await api(`/api/workspaces/${scope.workspace}/local-runners?page=${page}&q=${encodeURIComponent(options.search ?? '')}`, auth(scope, options.signal));
|
|
549
|
+
if (!localRunnersMatch(result, scope.owner, scope.workspace, options.search ?? '', Number(page)))
|
|
550
|
+
throw new CliError('runner_receipt_invalid', 'The runner history does not match this account, workspace or selected page.');
|
|
551
|
+
output('runner_list', result, result.results.map(item => `${item.device_key} ${item.label} ${item.state}`).join('\n') || 'No registered local runners.');
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!key)
|
|
555
|
+
throw new CliError('runner_key_required', 'Choose the exact runner key from firedrill runner list.', 2);
|
|
556
|
+
key = identifier(key);
|
|
557
|
+
if (action === 'revoke') {
|
|
558
|
+
output('runner_revocation_notice', { device_key: key }, 'Revocation prevents new native cleanup reports from this device. Finish cleanup before revoking when safe; security revocation proceeds immediately and does not stop local processes.');
|
|
559
|
+
// Authenticated revocation works even if the local private key/journal was lost.
|
|
560
|
+
const receipt = await runnerReceipt(scope, key, { ...auth(scope, options.signal), method: 'DELETE' });
|
|
561
|
+
if (receipt.device_key !== key || receipt.state !== 'revoked')
|
|
562
|
+
throw new CliError('runner_receipt_invalid', 'Runner revocation was not confirmed. Inspect its saved receipt.');
|
|
563
|
+
// Revocation is an immediate server-side authority fence, not proof that a
|
|
564
|
+
// claimed host container is absent. Preserve the original OS-stored key
|
|
565
|
+
// and journals for historical records; their retention does not authorize
|
|
566
|
+
// native cleanup submissions after revocation.
|
|
567
|
+
// Remote revocation also remains possible without this host's key/journal.
|
|
568
|
+
output('runner_revoked', receipt, describe(receipt) + '\nThe saved key and journals are retained, but this revoked device cannot submit new native cleanup reports. Revocation does not prove that local processes stopped.');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (action === 'status') {
|
|
572
|
+
const receipt = await runnerReceipt(scope, key, auth(scope, options.signal));
|
|
573
|
+
output('runner_receipt', receipt, describe(receipt));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
return lock(scope, key, options.signal, async (active, ownership) => {
|
|
577
|
+
options = { ...options, signal: active };
|
|
578
|
+
const value = await saved(scope, key);
|
|
579
|
+
const session = await api('/api/auth/native/session', auth(scope, options.signal));
|
|
580
|
+
if (session.id !== value.enrollment.native_session_id || value.stage === 'revoked') {
|
|
581
|
+
throw new CliError('runner_scope_changed', 'This runner belongs to another terminal session or was revoked. Enroll a new identity.');
|
|
582
|
+
}
|
|
583
|
+
const receipt = await pulse(value, 'idle', options.signal);
|
|
584
|
+
output('runner_connected', receipt, 'Runner presence connected. No execution permission has been granted. Press Ctrl+C to disconnect.');
|
|
585
|
+
try {
|
|
586
|
+
while (!options.signal.aborted) {
|
|
587
|
+
await sleep(20000, undefined, { signal: options.signal });
|
|
588
|
+
await pulse(value, 'idle', options.signal);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (!options.signal.aborted)
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
// Never wait on/replay an uncertain pulse while shutting down. Its short
|
|
597
|
+
// server presence lease expires; the journal remains for reconciliation.
|
|
598
|
+
if (!value.pending) {
|
|
599
|
+
const stop = new AbortController();
|
|
600
|
+
const timer = setTimeout(() => stop.abort(), 8000);
|
|
601
|
+
try {
|
|
602
|
+
output('runner_disconnected', await pulse(value, 'stopped', AbortSignal.any([stop.signal, ownership])), 'Runner presence disconnected.');
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
output('runner_disconnect_pending', { device_key: value.key }, 'Disconnect was not confirmed. The presence lease expires; inspect this runner before reconnecting.');
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
else
|
|
612
|
+
output('runner_disconnect_pending', { device_key: value.key }, 'A presence response is unresolved. Its lease expires; the exact journal is retained for reconnection.');
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|