@hivemagnus/magnus-server 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +171 -0
- package/LICENSE +202 -0
- package/README.md +169 -2
- package/agent-version.json +1 -0
- package/dist/ai/HEARTBEAT.md +56 -0
- package/dist/ai/IDENTITY.md +54 -0
- package/dist/ai/MEMORY.md +40 -0
- package/dist/ai/TOOLS.md +538 -0
- package/dist/ai/USER.md +32 -0
- package/dist/cli/args.js +62 -0
- package/dist/cli/env-catalog.js +131 -0
- package/dist/cli/preflight.js +89 -0
- package/dist/cli.js +175 -0
- package/dist/config/cors.js +16 -0
- package/dist/config/env.js +392 -0
- package/dist/config/features.js +16 -0
- package/dist/config/index.js +3 -0
- package/dist/config/listener-security.js +38 -0
- package/dist/config/version.js +31 -0
- package/dist/db/client.js +44 -0
- package/dist/db/generated/prisma/browser.js +18 -0
- package/dist/db/generated/prisma/client.js +35 -0
- package/dist/db/generated/prisma/commonInputTypes.js +11 -0
- package/dist/db/generated/prisma/enums.js +42 -0
- package/dist/db/generated/prisma/internal/class.js +50 -0
- package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
- package/dist/db/generated/prisma/models/A2ATask.js +2 -0
- package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
- package/dist/db/generated/prisma/models/Agent.js +2 -0
- package/dist/db/generated/prisma/models/AiConversation.js +2 -0
- package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
- package/dist/db/generated/prisma/models/AiMemory.js +2 -0
- package/dist/db/generated/prisma/models/AiMessage.js +2 -0
- package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
- package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
- package/dist/db/generated/prisma/models/AlertRule.js +2 -0
- package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
- package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
- package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
- package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/CommandResult.js +2 -0
- package/dist/db/generated/prisma/models/CronJob.js +2 -0
- package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
- package/dist/db/generated/prisma/models/DeployJob.js +2 -0
- package/dist/db/generated/prisma/models/DriftScan.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
- package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
- package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
- package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesHook.js +2 -0
- package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
- package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
- package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
- package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
- package/dist/db/generated/prisma/models/HermesRole.js +2 -0
- package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
- package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
- package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
- package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
- package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
- package/dist/db/generated/prisma/models/LogEntry.js +2 -0
- package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
- package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
- package/dist/db/generated/prisma/models/Node.js +2 -0
- package/dist/db/generated/prisma/models/Notification.js +2 -0
- package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
- package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
- package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
- package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
- package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
- package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
- package/dist/db/generated/prisma/models/Role.js +2 -0
- package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
- package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
- package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
- package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
- package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
- package/dist/db/generated/prisma/models/SkillTag.js +2 -0
- package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
- package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
- package/dist/db/generated/prisma/models/User.js +2 -0
- package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
- package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
- package/dist/db/generated/prisma/models.js +2 -0
- package/dist/db/index.js +3 -0
- package/dist/db/read-retry.js +35 -0
- package/dist/db/seed.js +348 -0
- package/dist/index.js +162 -0
- package/dist/middleware/auth.js +361 -0
- package/dist/middleware/body-limit.js +14 -0
- package/dist/middleware/correlation-id.js +13 -0
- package/dist/middleware/error-handler.js +28 -0
- package/dist/middleware/iam-token.js +21 -0
- package/dist/middleware/rate-limit.js +78 -0
- package/dist/middleware/request-logger.js +85 -0
- package/dist/middleware/security-headers.js +23 -0
- package/dist/middleware/tracing.js +120 -0
- package/dist/middleware/user-status.js +33 -0
- package/dist/openapi/spec.js +2656 -0
- package/dist/queue/redis.js +20 -0
- package/dist/queue/requeue.js +27 -0
- package/dist/queue/workers.js +342 -0
- package/dist/routes/ai-schedule.js +73 -0
- package/dist/routes/alerts.js +79 -0
- package/dist/routes/anomalies.js +101 -0
- package/dist/routes/ardent.js +82 -0
- package/dist/routes/assistant.js +417 -0
- package/dist/routes/audit.js +138 -0
- package/dist/routes/codex.js +65 -0
- package/dist/routes/commands.js +226 -0
- package/dist/routes/credentials.js +58 -0
- package/dist/routes/cron.js +292 -0
- package/dist/routes/dependencies.js +49 -0
- package/dist/routes/deploy.js +18 -0
- package/dist/routes/discovery.js +168 -0
- package/dist/routes/docs.js +24 -0
- package/dist/routes/drift.js +126 -0
- package/dist/routes/events-stream.js +87 -0
- package/dist/routes/files.js +214 -0
- package/dist/routes/fleet-checks.js +21 -0
- package/dist/routes/health.js +133 -0
- package/dist/routes/heartbeat.js +545 -0
- package/dist/routes/hermes-registry.js +278 -0
- package/dist/routes/hermes.js +61 -0
- package/dist/routes/library.js +402 -0
- package/dist/routes/logs.js +172 -0
- package/dist/routes/managed-cron.js +23 -0
- package/dist/routes/mcp.js +39 -0
- package/dist/routes/metrics.js +187 -0
- package/dist/routes/nodes.js +951 -0
- package/dist/routes/notifications.js +130 -0
- package/dist/routes/ops.js +258 -0
- package/dist/routes/orchestra.js +430 -0
- package/dist/routes/predictions.js +23 -0
- package/dist/routes/profiling.js +68 -0
- package/dist/routes/quick-command.js +72 -0
- package/dist/routes/remediation.js +111 -0
- package/dist/routes/resource-leases.js +297 -0
- package/dist/routes/roles.js +370 -0
- package/dist/routes/rollout.js +124 -0
- package/dist/routes/scan.js +116 -0
- package/dist/routes/security-backup.js +58 -0
- package/dist/routes/security-fusion.js +605 -0
- package/dist/routes/security-integration.js +71 -0
- package/dist/routes/self-update.js +129 -0
- package/dist/routes/shared-resource-leases.js +78 -0
- package/dist/routes/skills.js +485 -0
- package/dist/routes/stats.js +96 -0
- package/dist/routes/team-approvals.js +23 -0
- package/dist/routes/terminal-ticket.js +15 -0
- package/dist/routes/terminal.js +17 -0
- package/dist/routes/topology.js +37 -0
- package/dist/routes/users.js +219 -0
- package/dist/routes/webhooks.js +137 -0
- package/dist/schemas/command-reconciliation.js +14 -0
- package/dist/schemas/commands.js +51 -0
- package/dist/schemas/cron.js +33 -0
- package/dist/schemas/deploy.js +20 -0
- package/dist/schemas/files.js +14 -0
- package/dist/schemas/hermes.js +69 -0
- package/dist/schemas/index.js +11 -0
- package/dist/schemas/library.js +48 -0
- package/dist/schemas/logs.js +35 -0
- package/dist/schemas/managed-cron.js +127 -0
- package/dist/schemas/node-responses.js +87 -0
- package/dist/schemas/node-session-receipt.js +84 -0
- package/dist/schemas/nodes.js +36 -0
- package/dist/schemas/orchestra.js +118 -0
- package/dist/schemas/resource-participant-reconciliation.js +99 -0
- package/dist/schemas/roles.js +54 -0
- package/dist/schemas/shared-resource-lease.js +132 -0
- package/dist/schemas/skills.js +44 -0
- package/dist/schemas/users.js +28 -0
- package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/node-api-client.js +474 -0
- package/dist/security/approval-assertion.js +66 -0
- package/dist/security/command-completion-registry.js +35 -0
- package/dist/security/command-request.js +12 -0
- package/dist/security/command-result-signature.js +34 -0
- package/dist/security/managed-http.js +45 -0
- package/dist/security/native-completion-registry.js +44 -0
- package/dist/server.js +178 -0
- package/dist/services/admission-fence.js +71 -0
- package/dist/services/ai-assistant-service.js +2523 -0
- package/dist/services/ai-correlation-service.js +225 -0
- package/dist/services/ai-export-service.js +141 -0
- package/dist/services/ai-insights-service.js +219 -0
- package/dist/services/ai-schedule-service.js +145 -0
- package/dist/services/ai-scratchpad-service.js +222 -0
- package/dist/services/ai-tools.js +1343 -0
- package/dist/services/alert-service.js +313 -0
- package/dist/services/anomaly-detector.js +419 -0
- package/dist/services/api-local-work.js +79 -0
- package/dist/services/application-probes.js +309 -0
- package/dist/services/ardent-scope-enrollment.js +123 -0
- package/dist/services/ardent-service.js +86 -0
- package/dist/services/audit-service.js +308 -0
- package/dist/services/codex-service.js +209 -0
- package/dist/services/command-lifecycle.js +60 -0
- package/dist/services/command-resource-leases.js +305 -0
- package/dist/services/control-host-node.js +242 -0
- package/dist/services/credential-rotation-service.js +294 -0
- package/dist/services/dependency-service.js +145 -0
- package/dist/services/disposable-recovery-runner.js +595 -0
- package/dist/services/docker-group-health.js +121 -0
- package/dist/services/engineering-assurance.js +492 -0
- package/dist/services/engineering-evidence-ledger.js +94 -0
- package/dist/services/engineering-metric-evidence.js +26 -0
- package/dist/services/event-bus.js +227 -0
- package/dist/services/fleet-check-log.js +102 -0
- package/dist/services/forecast-service.js +154 -0
- package/dist/services/health-retention.js +175 -0
- package/dist/services/heartbeat-stream.js +63 -0
- package/dist/services/hermes-catalog-import.js +62 -0
- package/dist/services/hermes-cli-parser.js +671 -0
- package/dist/services/hermes-core-rollout.js +90 -0
- package/dist/services/hermes-dashboard-endpoint.js +13 -0
- package/dist/services/hermes-plugin-source.js +149 -0
- package/dist/services/hermes-registry-service.js +258 -0
- package/dist/services/hermes-service.js +566 -0
- package/dist/services/hivemem-client.js +85 -0
- package/dist/services/host-local-writer.js +82 -0
- package/dist/services/incident-evidence.js +110 -0
- package/dist/services/incident-lessons.js +186 -0
- package/dist/services/llm-providers/anthropic.js +143 -0
- package/dist/services/llm-providers/index.js +7 -0
- package/dist/services/llm-providers/llama-cpp.js +237 -0
- package/dist/services/llm-providers/ollama.js +292 -0
- package/dist/services/llm-providers/openai-compatible.js +200 -0
- package/dist/services/llm-providers/provider-error.js +36 -0
- package/dist/services/llm-providers/registry.js +45 -0
- package/dist/services/llm-providers/types.js +2 -0
- package/dist/services/log-emitter.js +19 -0
- package/dist/services/log-retention.js +66 -0
- package/dist/services/maintenance-service.js +29 -0
- package/dist/services/managed-cron.js +493 -0
- package/dist/services/managed-ssh-admission.js +68 -0
- package/dist/services/monitor-pulse.js +10 -0
- package/dist/services/node-issue-monitor.js +71 -0
- package/dist/services/node-service.js +35 -0
- package/dist/services/notification-retention.js +51 -0
- package/dist/services/notification-service.js +94 -0
- package/dist/services/operational-observations.js +112 -0
- package/dist/services/operations-config.js +67 -0
- package/dist/services/ops-service.js +491 -0
- package/dist/services/orchestra-extras-service.js +370 -0
- package/dist/services/orchestra-resources-service.js +530 -0
- package/dist/services/os-scheduler-writer.js +73 -0
- package/dist/services/password-service.js +22 -0
- package/dist/services/prediction-service.js +284 -0
- package/dist/services/recovery-exercises.js +682 -0
- package/dist/services/remediation-service.js +325 -0
- package/dist/services/resource-enrollment.js +433 -0
- package/dist/services/resource-node-lock.js +18 -0
- package/dist/services/resource-participant-work.js +302 -0
- package/dist/services/role-service.js +388 -0
- package/dist/services/rollout-service.js +440 -0
- package/dist/services/security-export.js +185 -0
- package/dist/services/security-fusion-actions.js +22 -0
- package/dist/services/security-fusion-approvals.js +83 -0
- package/dist/services/security-fusion-evidence-integrity.js +144 -0
- package/dist/services/security-fusion-ingest.js +101 -0
- package/dist/services/security-fusion-outbox.js +185 -0
- package/dist/services/security-fusion-receipt-chain.js +134 -0
- package/dist/services/security-fusion-service.js +751 -0
- package/dist/services/security-fusion-verification.js +50 -0
- package/dist/services/self-update-service.js +200 -0
- package/dist/services/shared-resource-leases.js +413 -0
- package/dist/services/skill-publication-bundle.js +90 -0
- package/dist/services/skill-publication-settlement.js +36 -0
- package/dist/services/skill-publication-state.js +40 -0
- package/dist/services/skill-registry-service.js +327 -0
- package/dist/services/skill-service.js +148 -0
- package/dist/services/ssh-exec-service.js +139 -0
- package/dist/services/ssh-host-verification.js +27 -0
- package/dist/services/tailscale-node-key-service.js +75 -0
- package/dist/services/team-approvals.js +118 -0
- package/dist/services/topology-service.js +138 -0
- package/dist/services/transfer-authority-snapshot.js +151 -0
- package/dist/services/webhook-credential-migration.js +97 -0
- package/dist/services/webhook-security.js +242 -0
- package/dist/services/webhook-service.js +494 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/index.js +3 -0
- package/dist/types/library.js +3 -0
- package/dist/types/notifications.js +3 -0
- package/dist/types/roles.js +3 -0
- package/dist/types/shared-contracts.js +3 -0
- package/dist/utils/env.js +141 -0
- package/dist/utils/heartbeat-peer.js +19 -0
- package/dist/utils/idempotency.js +45 -0
- package/dist/utils/ip.js +25 -0
- package/dist/utils/json-fields.js +94 -0
- package/dist/utils/local-tls.js +39 -0
- package/dist/utils/magnus-home.js +15 -0
- package/dist/utils/node-url.js +59 -0
- package/dist/utils/redact.js +26 -0
- package/dist/utils/safe-equal.js +13 -0
- package/dist/utils/sentinel-nodes.js +16 -0
- package/dist/utils/validate.js +41 -0
- package/package.json +119 -4
- package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +2185 -0
- package/prisma.config.ts +14 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants as fsConstants, cpSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { loadRecoveryRunnerTrust, recordRecoveryExerciseFromDedicatedRunner, recoveryEvidenceSha256, recoveryExerciseCollectorSchema, recoveryExerciseContractSha256, recoveryRunnerReceiptSigningBytes, } from './recovery-exercises.js';
|
|
7
|
+
const scenarioSchema = z.enum(['backup_restore', 'rollback', 'specialist_loss', 'coordinator_loss']);
|
|
8
|
+
const fixtureNamePattern = /^magnus-disposable-[a-zA-Z0-9]{6}$/;
|
|
9
|
+
const fixtureOwnerFile = '.magnus-disposable-owner.json';
|
|
10
|
+
export const DISPOSABLE_RECOVERY_ORPHAN_AGE_MS = 60 * 60_000;
|
|
11
|
+
const fixtureOwnerSchema = z.object({
|
|
12
|
+
schemaVersion: z.literal(1),
|
|
13
|
+
fixtureName: z.string().regex(fixtureNamePattern),
|
|
14
|
+
runnerPid: z.number().int().positive(),
|
|
15
|
+
runnerBootId: z.string().guid(),
|
|
16
|
+
runnerStartTimeTicks: z.string().regex(/^[0-9]+$/),
|
|
17
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
18
|
+
}).strict();
|
|
19
|
+
const privateKeyFileSchema = z.object({
|
|
20
|
+
schemaVersion: z.literal(1),
|
|
21
|
+
issuer: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9:._/-]{0,159}$/),
|
|
22
|
+
keyId: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9:._/-]{0,159}$/),
|
|
23
|
+
privateKeyPem: z.string().min(64).max(8_192),
|
|
24
|
+
}).strict();
|
|
25
|
+
export function assertDedicatedRecoveryDatabaseUrl(raw) {
|
|
26
|
+
if (!raw)
|
|
27
|
+
throw new Error('DATABASE_URL is required for the dedicated recovery runner');
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('DATABASE_URL must be a valid PostgreSQL URL');
|
|
34
|
+
}
|
|
35
|
+
if (!['postgres:', 'postgresql:'].includes(parsed.protocol) || parsed.username !== 'magnus_recovery_runner')
|
|
36
|
+
throw new Error('DATABASE_URL must use the dedicated magnus_recovery_runner PostgreSQL role');
|
|
37
|
+
if (!parsed.password || !parsed.hostname || !parsed.pathname || parsed.pathname === '/')
|
|
38
|
+
throw new Error('DATABASE_URL must include the dedicated role credential, host, and database');
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
function sha256(value) {
|
|
42
|
+
return createHash('sha256').update(value).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
function currentUid() {
|
|
45
|
+
if (typeof process.getuid !== 'function')
|
|
46
|
+
throw new Error('Recovery runner requires a POSIX user identity');
|
|
47
|
+
return process.getuid();
|
|
48
|
+
}
|
|
49
|
+
function readStablePrivateKey(path) {
|
|
50
|
+
let descriptor;
|
|
51
|
+
let contents = '';
|
|
52
|
+
try {
|
|
53
|
+
descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
54
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
55
|
+
if (!before.isFile() || before.nlink !== 1n || before.size > 16384n
|
|
56
|
+
|| (Number(before.mode) & 0o077) !== 0 || Number(before.uid) !== currentUid())
|
|
57
|
+
throw new Error('Unsafe recovery runner private-key file');
|
|
58
|
+
const buffer = Buffer.alloc(Number(before.size) + 1);
|
|
59
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
60
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
61
|
+
if (bytesRead !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino
|
|
62
|
+
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|
|
63
|
+
|| before.nlink !== after.nlink || after.nlink !== 1n || before.mode !== after.mode
|
|
64
|
+
|| before.uid !== after.uid || before.gid !== after.gid || (Number(after.mode) & 0o077) !== 0)
|
|
65
|
+
throw new Error('Recovery runner private-key file changed while reading');
|
|
66
|
+
contents = buffer.subarray(0, bytesRead).toString('utf8');
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (descriptor !== undefined)
|
|
70
|
+
closeSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
const configured = privateKeyFileSchema.parse(JSON.parse(contents));
|
|
73
|
+
const privateKey = createPrivateKey(configured.privateKeyPem);
|
|
74
|
+
if (privateKey.asymmetricKeyType !== 'ed25519')
|
|
75
|
+
throw new Error('Recovery runner private key must be Ed25519');
|
|
76
|
+
return { ...configured, privateKey };
|
|
77
|
+
}
|
|
78
|
+
function assertSafeRoot(path) {
|
|
79
|
+
const resolved = resolve(path);
|
|
80
|
+
const state = lstatSync(resolved, { bigint: true });
|
|
81
|
+
if (!state.isDirectory() || state.nlink < 1n || (Number(state.mode) & 0o022) !== 0
|
|
82
|
+
|| Number(state.uid) !== currentUid())
|
|
83
|
+
throw new Error('Disposable recovery runner root must be an owner-controlled directory');
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
function linuxBootId() {
|
|
87
|
+
return z.string().guid().parse(readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim());
|
|
88
|
+
}
|
|
89
|
+
function linuxProcessStartTimeTicks(pid) {
|
|
90
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
|
|
91
|
+
const commandEnd = stat.lastIndexOf(') ');
|
|
92
|
+
if (commandEnd < 0)
|
|
93
|
+
throw new Error('Linux process stat is malformed');
|
|
94
|
+
const fieldsAfterCommand = stat.slice(commandEnd + 2).split(' ');
|
|
95
|
+
const startTimeTicks = fieldsAfterCommand[19];
|
|
96
|
+
if (!startTimeTicks || !/^[0-9]+$/.test(startTimeTicks))
|
|
97
|
+
throw new Error('Linux process start time is malformed');
|
|
98
|
+
return startTimeTicks;
|
|
99
|
+
}
|
|
100
|
+
function linuxProcessIdentity(pid) {
|
|
101
|
+
return { bootId: linuxBootId(), startTimeTicks: linuxProcessStartTimeTicks(pid) };
|
|
102
|
+
}
|
|
103
|
+
function processIdentityState(owner) {
|
|
104
|
+
let bootId;
|
|
105
|
+
try {
|
|
106
|
+
bootId = linuxBootId();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 'unknown';
|
|
110
|
+
}
|
|
111
|
+
if (bootId !== owner.runnerBootId)
|
|
112
|
+
return 'dead';
|
|
113
|
+
try {
|
|
114
|
+
return linuxProcessStartTimeTicks(owner.runnerPid) === owner.runnerStartTimeTicks ? 'alive' : 'dead';
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? 'dead' : 'unknown';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function readFixtureOwner(path) {
|
|
121
|
+
let descriptor;
|
|
122
|
+
try {
|
|
123
|
+
descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
124
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
125
|
+
if (!before.isFile() || before.nlink !== 1n || before.size > 4096n
|
|
126
|
+
|| (Number(before.mode) & 0o077) !== 0 || Number(before.uid) !== currentUid())
|
|
127
|
+
throw new Error('Unsafe disposable fixture owner marker');
|
|
128
|
+
const buffer = Buffer.alloc(Number(before.size) + 1);
|
|
129
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
130
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
131
|
+
if (bytesRead !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino
|
|
132
|
+
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|
|
133
|
+
|| before.nlink !== after.nlink || after.nlink !== 1n || before.mode !== after.mode
|
|
134
|
+
|| before.uid !== after.uid || before.gid !== after.gid || (Number(after.mode) & 0o077) !== 0)
|
|
135
|
+
throw new Error('Disposable fixture owner marker changed while reading');
|
|
136
|
+
return fixtureOwnerSchema.parse(JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (descriptor !== undefined)
|
|
140
|
+
closeSync(descriptor);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function writeFixtureOwner(fixtureRoot) {
|
|
144
|
+
const fixtureName = basename(fixtureRoot);
|
|
145
|
+
const processIdentity = linuxProcessIdentity(process.pid);
|
|
146
|
+
const finalPath = join(fixtureRoot, fixtureOwnerFile);
|
|
147
|
+
const temporaryPath = join(fixtureRoot, `${fixtureOwnerFile}.${randomUUID()}.tmp`);
|
|
148
|
+
let fileDescriptor;
|
|
149
|
+
let directoryDescriptor;
|
|
150
|
+
let renamed = false;
|
|
151
|
+
try {
|
|
152
|
+
fileDescriptor = openSync(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
153
|
+
writeFileSync(fileDescriptor, JSON.stringify({
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
fixtureName,
|
|
156
|
+
runnerPid: process.pid,
|
|
157
|
+
runnerBootId: processIdentity.bootId,
|
|
158
|
+
runnerStartTimeTicks: processIdentity.startTimeTicks,
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
}) + '\n');
|
|
161
|
+
fsyncSync(fileDescriptor);
|
|
162
|
+
closeSync(fileDescriptor);
|
|
163
|
+
fileDescriptor = undefined;
|
|
164
|
+
renameSync(temporaryPath, finalPath);
|
|
165
|
+
renamed = true;
|
|
166
|
+
directoryDescriptor = openSync(fixtureRoot, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
167
|
+
fsyncSync(directoryDescriptor);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (fileDescriptor !== undefined)
|
|
171
|
+
closeSync(fileDescriptor);
|
|
172
|
+
if (directoryDescriptor !== undefined)
|
|
173
|
+
closeSync(directoryDescriptor);
|
|
174
|
+
if (!renamed)
|
|
175
|
+
rmSync(temporaryPath, { force: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Remove only expired directories in the runner-reserved namespace. The missing-marker
|
|
180
|
+
* case covers termination in the small interval between mkdtemp and marker creation.
|
|
181
|
+
*/
|
|
182
|
+
export function reapOrphanedDisposableRecoveryFixtures(rootPath, nowMs = Date.now(), hooks = {}) {
|
|
183
|
+
if (!isAbsolute(rootPath))
|
|
184
|
+
throw new Error('Recovery runner root must be absolute');
|
|
185
|
+
const root = assertSafeRoot(rootPath);
|
|
186
|
+
const result = {
|
|
187
|
+
reaped: [], preservedYoung: [], preservedActive: [], preservedUnsafe: [],
|
|
188
|
+
};
|
|
189
|
+
const entries = readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'));
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (!fixtureNamePattern.test(entry.name))
|
|
192
|
+
continue;
|
|
193
|
+
const fixtureRoot = join(root, entry.name);
|
|
194
|
+
let initial;
|
|
195
|
+
try {
|
|
196
|
+
initial = lstatSync(fixtureRoot, { bigint: true });
|
|
197
|
+
if (!initial.isDirectory() || Number(initial.uid) !== currentUid() || (Number(initial.mode) & 0o077) !== 0) {
|
|
198
|
+
result.preservedUnsafe.push(entry.name);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
let createdAtMs = Number(initial.mtimeMs);
|
|
202
|
+
try {
|
|
203
|
+
const owner = readFixtureOwner(join(fixtureRoot, fixtureOwnerFile));
|
|
204
|
+
if (owner.fixtureName !== entry.name)
|
|
205
|
+
throw new Error('Disposable fixture owner marker does not match its directory');
|
|
206
|
+
createdAtMs = Date.parse(owner.createdAt);
|
|
207
|
+
const identityState = processIdentityState(owner);
|
|
208
|
+
if (identityState !== 'dead') {
|
|
209
|
+
if (identityState === 'unknown')
|
|
210
|
+
result.preservedUnsafe.push(entry.name);
|
|
211
|
+
else
|
|
212
|
+
result.preservedActive.push(entry.name);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
218
|
+
result.preservedUnsafe.push(entry.name);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isFinite(createdAtMs) || createdAtMs > nowMs
|
|
223
|
+
|| nowMs - createdAtMs < DISPOSABLE_RECOVERY_ORPHAN_AGE_MS) {
|
|
224
|
+
result.preservedYoung.push(entry.name);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const final = lstatSync(fixtureRoot, { bigint: true });
|
|
228
|
+
if (!final.isDirectory() || final.dev !== initial.dev || final.ino !== initial.ino
|
|
229
|
+
|| final.uid !== initial.uid || final.mode !== initial.mode) {
|
|
230
|
+
result.preservedUnsafe.push(entry.name);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const quarantinePath = join(root, `${entry.name}.reap-${randomUUID()}`);
|
|
234
|
+
try {
|
|
235
|
+
renameSync(fixtureRoot, quarantinePath);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
239
|
+
continue;
|
|
240
|
+
result.preservedUnsafe.push(entry.name);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
hooks.afterQuarantine?.(fixtureRoot, quarantinePath);
|
|
244
|
+
try {
|
|
245
|
+
const quarantined = lstatSync(quarantinePath, { bigint: true });
|
|
246
|
+
if (!quarantined.isDirectory() || quarantined.dev !== initial.dev || quarantined.ino !== initial.ino
|
|
247
|
+
|| quarantined.uid !== initial.uid || quarantined.mode !== initial.mode) {
|
|
248
|
+
result.preservedUnsafe.push(entry.name);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
rmSync(quarantinePath, { recursive: true, force: true });
|
|
252
|
+
result.reaped.push(entry.name);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
result.preservedUnsafe.push(entry.name);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
function walkFiles(root, relative = '') {
|
|
266
|
+
const directory = join(root, relative);
|
|
267
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'));
|
|
268
|
+
const output = [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile()))
|
|
271
|
+
throw new Error('Disposable fixture contains an unsupported filesystem entry');
|
|
272
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
273
|
+
if (entry.isDirectory())
|
|
274
|
+
output.push(...walkFiles(root, child));
|
|
275
|
+
else
|
|
276
|
+
output.push({ path: child, sha256: sha256(readFileSync(join(root, child))) });
|
|
277
|
+
}
|
|
278
|
+
return output;
|
|
279
|
+
}
|
|
280
|
+
function captureSnapshot(dataRoot) {
|
|
281
|
+
const state = walkFiles(join(dataRoot, 'state')).map(entry => ({ key: entry.path, valueSha256: entry.sha256 }));
|
|
282
|
+
const tasks = z.array(z.object({ taskId: z.string(), status: z.enum(['completed', 'cancelled', 'failed']),
|
|
283
|
+
receiptSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).parse(JSON.parse(readFileSync(join(dataRoot, 'task-receipts.json'), 'utf8')))
|
|
284
|
+
.sort((a, b) => a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0);
|
|
285
|
+
const artifacts = walkFiles(join(dataRoot, 'artifacts')).map(entry => ({ artifactId: entry.path, sha256: entry.sha256 }));
|
|
286
|
+
return { state, taskReceipts: tasks, artifacts };
|
|
287
|
+
}
|
|
288
|
+
function snapshotDigests(snapshot) {
|
|
289
|
+
return {
|
|
290
|
+
stateSha256: recoveryEvidenceSha256(snapshot.state),
|
|
291
|
+
taskSha256: recoveryEvidenceSha256(snapshot.taskReceipts),
|
|
292
|
+
artifactSha256: recoveryEvidenceSha256(snapshot.artifacts),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function evidence(kind, phase, observedAt, source, subjectSha256, observation) {
|
|
296
|
+
return { kind, phase, status: 'pass', observedAt: observedAt.toISOString(), source, subjectSha256, observation };
|
|
297
|
+
}
|
|
298
|
+
function seedFixture(dataRoot) {
|
|
299
|
+
mkdirSync(join(dataRoot, 'state'), { recursive: true, mode: 0o700 });
|
|
300
|
+
mkdirSync(join(dataRoot, 'artifacts'), { recursive: true, mode: 0o700 });
|
|
301
|
+
writeFileSync(join(dataRoot, 'state', 'coordinator.json'), JSON.stringify({ leader: 'coordinator', epoch: 7 }) + '\n', { mode: 0o600 });
|
|
302
|
+
writeFileSync(join(dataRoot, 'state', 'specialists.json'), JSON.stringify({ ready: ['canary', 'magnus', 'edge', 'guard'] }) + '\n', { mode: 0o600 });
|
|
303
|
+
writeFileSync(join(dataRoot, 'artifacts', 'manifest.json'), JSON.stringify({ revision: 3, artifact: sha256('fixture-artifact') }) + '\n', { mode: 0o600 });
|
|
304
|
+
writeFileSync(join(dataRoot, 'task-receipts.json'), JSON.stringify([
|
|
305
|
+
{ taskId: 'fixture-task', status: 'completed', receiptSha256: sha256('fixture-task-receipt') },
|
|
306
|
+
]) + '\n', { mode: 0o600 });
|
|
307
|
+
}
|
|
308
|
+
function observeProcessLoss(kind, role, payload) {
|
|
309
|
+
const program = `
|
|
310
|
+
const { createHash } = require('node:crypto');
|
|
311
|
+
const { spawn } = require('node:child_process');
|
|
312
|
+
const kind = process.argv[1]; const role = process.argv[2]; const payload = process.argv[3];
|
|
313
|
+
const workerProgram = \`
|
|
314
|
+
const {createHash}=require('node:crypto'); const net=require('node:net');
|
|
315
|
+
const kind=process.argv[1]; const role=process.argv[2]; const payload=process.argv[3];
|
|
316
|
+
const workSha256=createHash('sha256').update(payload).digest('hex');
|
|
317
|
+
if(kind==='coordinator'){
|
|
318
|
+
const server=net.createServer();
|
|
319
|
+
server.listen({host:'127.0.0.1',port:0,exclusive:true},()=>process.send({type:'accepted',role,pid:process.pid,workSha256,port:server.address().port,epoch:1}));
|
|
320
|
+
}else{process.send({type:'accepted',role,pid:process.pid,workSha256,port:0,epoch:0});setInterval(()=>{},1000)}
|
|
321
|
+
\`;
|
|
322
|
+
const child=spawn(process.execPath,['-e',workerProgram,kind,role,payload],{stdio:['ignore','ignore','ignore','ipc']});
|
|
323
|
+
const timeout=setTimeout(()=>{child.kill('SIGKILL');process.exit(2)},1500);
|
|
324
|
+
child.once('message',accepted=>{
|
|
325
|
+
if(!accepted||accepted.type!=='accepted'||accepted.role!==role||accepted.pid!==child.pid)process.exit(3);
|
|
326
|
+
child.once('exit',(code,signal)=>{
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
if(code!==null||signal!=='SIGKILL')process.exit(4);
|
|
329
|
+
process.stdout.write(JSON.stringify({kind,role,pid:accepted.pid,workSha256:accepted.workSha256,
|
|
330
|
+
port:accepted.port,epoch:accepted.epoch,lossSignal:signal}));
|
|
331
|
+
});
|
|
332
|
+
child.kill('SIGKILL');
|
|
333
|
+
});
|
|
334
|
+
`;
|
|
335
|
+
const result = spawnSync(process.execPath, ['-e', program, kind, role, payload], {
|
|
336
|
+
encoding: 'utf8', timeout: 5_000, maxBuffer: 4_096, windowsHide: true,
|
|
337
|
+
});
|
|
338
|
+
if (result.status !== 0 || result.signal || !result.stdout)
|
|
339
|
+
throw new Error(`Disposable ${kind} original process loss was not observed`);
|
|
340
|
+
const observed = z.object({
|
|
341
|
+
kind: z.literal(kind), role: z.literal(role), pid: z.number().int().positive(),
|
|
342
|
+
workSha256: z.string().regex(/^[a-f0-9]{64}$/), epoch: z.number().int().nonnegative(),
|
|
343
|
+
port: z.number().int().nonnegative().max(65_535), lossSignal: z.literal('SIGKILL'),
|
|
344
|
+
}).strict().parse(JSON.parse(result.stdout));
|
|
345
|
+
if (observed.workSha256 !== sha256(payload))
|
|
346
|
+
throw new Error('Lost worker did not accept the bound work');
|
|
347
|
+
if (kind === 'coordinator' && (observed.port < 1 || observed.epoch !== 1))
|
|
348
|
+
throw new Error('Lost coordinator did not hold the initial leadership endpoint');
|
|
349
|
+
if (kind === 'specialist' && (observed.port !== 0 || observed.epoch !== 0))
|
|
350
|
+
throw new Error('Lost specialist returned coordinator state');
|
|
351
|
+
return observed;
|
|
352
|
+
}
|
|
353
|
+
function runSuccessor(kind, role, payload, original) {
|
|
354
|
+
const program = `
|
|
355
|
+
const {createHash}=require('node:crypto'); const {spawnSync}=require('node:child_process'); const net=require('node:net');
|
|
356
|
+
const kind=process.argv[1]; const role=process.argv[2]; const payload=process.argv[3]; const port=Number(process.argv[4]); const epoch=Number(process.argv[5]);
|
|
357
|
+
const workSha256=createHash('sha256').update(payload).digest('hex');
|
|
358
|
+
if(kind==='specialist'){
|
|
359
|
+
process.stdout.write(JSON.stringify({kind,role,pid:process.pid,workSha256,port:0,epoch:0,contenderRejected:false}));
|
|
360
|
+
}else{
|
|
361
|
+
const server=net.createServer();
|
|
362
|
+
server.once('error',()=>process.exit(2));
|
|
363
|
+
server.listen({host:'127.0.0.1',port,exclusive:true},()=>{
|
|
364
|
+
const contenderProgram="const net=require('node:net');const s=net.createServer();s.once('error',e=>process.exit(e&&e.code==='EADDRINUSE'?73:74));s.listen({host:'127.0.0.1',port:Number(process.argv[1]),exclusive:true},()=>process.exit(0))";
|
|
365
|
+
const contender=spawnSync(process.execPath,['-e',contenderProgram,String(port)],{timeout:1000,stdio:'ignore'});
|
|
366
|
+
const contenderRejected=contender.status===73;
|
|
367
|
+
if(!contenderRejected)process.exit(3);
|
|
368
|
+
process.stdout.write(JSON.stringify({kind,role,pid:process.pid,workSha256,port,epoch,contenderRejected}));
|
|
369
|
+
server.close(()=>process.exit(0));
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
`;
|
|
373
|
+
const successorEpoch = kind === 'coordinator' ? original.epoch + 1 : 0;
|
|
374
|
+
const result = spawnSync(process.execPath, ['-e', program, kind, role, payload, String(original.port), String(successorEpoch)], { encoding: 'utf8', timeout: 5_000, maxBuffer: 4_096, windowsHide: true });
|
|
375
|
+
if (result.status !== 0 || result.signal || !result.stdout)
|
|
376
|
+
throw new Error(`Disposable ${kind} successor did not complete bound work`);
|
|
377
|
+
const observed = z.object({
|
|
378
|
+
kind: z.literal(kind), role: z.literal(role), pid: z.number().int().positive(),
|
|
379
|
+
workSha256: z.string().regex(/^[a-f0-9]{64}$/), epoch: z.number().int().nonnegative(),
|
|
380
|
+
port: z.number().int().nonnegative().max(65_535), contenderRejected: z.boolean(),
|
|
381
|
+
}).strict().parse(JSON.parse(result.stdout));
|
|
382
|
+
if (observed.pid === original.pid || observed.workSha256 !== original.workSha256
|
|
383
|
+
|| observed.workSha256 !== sha256(payload))
|
|
384
|
+
throw new Error('Disposable successor was not distinct and bound to the original work');
|
|
385
|
+
if (kind === 'coordinator' && (observed.port !== original.port
|
|
386
|
+
|| observed.epoch !== original.epoch + 1 || !observed.contenderRejected))
|
|
387
|
+
throw new Error('Disposable coordinator successor did not prove fenced leadership');
|
|
388
|
+
if (kind === 'specialist' && (observed.port !== 0 || observed.epoch !== 0 || observed.contenderRejected))
|
|
389
|
+
throw new Error('Disposable specialist successor returned coordinator state');
|
|
390
|
+
return observed;
|
|
391
|
+
}
|
|
392
|
+
function performScenario(scenario, root, now) {
|
|
393
|
+
const dataRoot = join(root, 'data');
|
|
394
|
+
const scratch = join(root, 'scenario');
|
|
395
|
+
mkdirSync(scratch, { mode: 0o700 });
|
|
396
|
+
if (scenario === 'backup_restore') {
|
|
397
|
+
const backup = join(scratch, 'backup');
|
|
398
|
+
cpSync(dataRoot, backup, { recursive: true, errorOnExist: true, force: false });
|
|
399
|
+
const backupSha256 = recoveryEvidenceSha256(walkFiles(backup));
|
|
400
|
+
const beforeObservedAt = now();
|
|
401
|
+
const startedAt = now();
|
|
402
|
+
rmSync(dataRoot, { recursive: true });
|
|
403
|
+
cpSync(backup, dataRoot, { recursive: true, errorOnExist: true, force: false });
|
|
404
|
+
const restoredSha256 = recoveryEvidenceSha256(walkFiles(dataRoot));
|
|
405
|
+
if (backupSha256 !== restoredSha256)
|
|
406
|
+
throw new Error('Disposable backup restore changed fixture bytes');
|
|
407
|
+
const recoveredAt = now();
|
|
408
|
+
const recoveryObservedAt = now();
|
|
409
|
+
return { startedAt, recoveredAt, rows: [
|
|
410
|
+
{ kind: 'backup_snapshot', phase: 'before', observedAt: beforeObservedAt,
|
|
411
|
+
observation: { backupId: basename(backup), backupSha256 } },
|
|
412
|
+
{ kind: 'restore_validation', phase: 'recovery', observedAt: recoveryObservedAt, observation: {
|
|
413
|
+
backupId: basename(backup), restoredBackupSha256: restoredSha256, restoreReady: true,
|
|
414
|
+
} },
|
|
415
|
+
] };
|
|
416
|
+
}
|
|
417
|
+
if (scenario === 'rollback') {
|
|
418
|
+
const fromRevision = sha256('disposable-release-from').slice(0, 40);
|
|
419
|
+
const toRevision = sha256('disposable-release-to').slice(0, 40);
|
|
420
|
+
const candidate = join(scratch, 'release.candidate');
|
|
421
|
+
const active = join(scratch, 'release.active');
|
|
422
|
+
writeFileSync(active, `${fromRevision}\n`, { mode: 0o600 });
|
|
423
|
+
if (readFileSync(active, 'utf8').trim() !== fromRevision)
|
|
424
|
+
throw new Error('Disposable rollback baseline did not become active');
|
|
425
|
+
writeFileSync(candidate, `${toRevision}\n`, { mode: 0o600 });
|
|
426
|
+
const beforeObservedAt = now();
|
|
427
|
+
const startedAt = now();
|
|
428
|
+
renameSync(candidate, active);
|
|
429
|
+
const activeRevision = readFileSync(active, 'utf8').trim();
|
|
430
|
+
if (activeRevision !== toRevision)
|
|
431
|
+
throw new Error('Disposable rollback target did not become active');
|
|
432
|
+
const recoveredAt = now();
|
|
433
|
+
const recoveryObservedAt = now();
|
|
434
|
+
return { startedAt, recoveredAt, rows: [
|
|
435
|
+
{ kind: 'release_baseline', phase: 'before', observedAt: beforeObservedAt,
|
|
436
|
+
observation: { fromRevision, toRevision } },
|
|
437
|
+
{ kind: 'rollback_validation', phase: 'recovery', observedAt: recoveryObservedAt, observation: {
|
|
438
|
+
fromRevision, toRevision, activeRevision, rollbackReady: true,
|
|
439
|
+
} },
|
|
440
|
+
] };
|
|
441
|
+
}
|
|
442
|
+
if (scenario === 'specialist_loss') {
|
|
443
|
+
const specialistId = 'specialist-fixture';
|
|
444
|
+
const successorId = 'magnus-fixture';
|
|
445
|
+
const payload = 'specialist:delegated-task:bounded-work-v1';
|
|
446
|
+
const original = observeProcessLoss('specialist', specialistId, payload);
|
|
447
|
+
const beforeObservedAt = now();
|
|
448
|
+
const startedAt = now();
|
|
449
|
+
const successor = runSuccessor('specialist', successorId, payload, original);
|
|
450
|
+
const recoveredAt = now();
|
|
451
|
+
const recoveryObservedAt = now();
|
|
452
|
+
const path = join(scratch, 'specialist-loss.json');
|
|
453
|
+
writeFileSync(path, JSON.stringify({ specialistId, successorId, original, successor }), { mode: 0o600 });
|
|
454
|
+
const observed = z.object({ specialistId: z.literal(specialistId), successorId: z.literal(successorId),
|
|
455
|
+
original: z.object({ pid: z.number().int().positive(), role: z.literal(specialistId), workSha256: z.string() }).passthrough(),
|
|
456
|
+
successor: z.object({ pid: z.number().int().positive(), role: z.literal(successorId), workSha256: z.string() }).passthrough(),
|
|
457
|
+
}).strict().parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
458
|
+
return { startedAt, recoveredAt, rows: [
|
|
459
|
+
{ kind: 'specialist_identity', phase: 'before', observedAt: beforeObservedAt,
|
|
460
|
+
observation: { specialistId: observed.specialistId, originalPid: original.pid,
|
|
461
|
+
workSha256: original.workSha256, lossSignal: original.lossSignal, lossConfirmed: true } },
|
|
462
|
+
{ kind: 'delegation_recovery', phase: 'recovery', observedAt: recoveryObservedAt, observation: {
|
|
463
|
+
specialistId: observed.specialistId, successorId: observed.successorId,
|
|
464
|
+
originalPid: original.pid, successorPid: successor.pid, workSha256: successor.workSha256,
|
|
465
|
+
originalIsolated: true, successorReady: true,
|
|
466
|
+
} },
|
|
467
|
+
] };
|
|
468
|
+
}
|
|
469
|
+
const coordinatorId = 'coordinator-fixture';
|
|
470
|
+
const successorId = 'magnus-fixture';
|
|
471
|
+
const payload = 'coordinator:mission-plan:bounded-work-v1';
|
|
472
|
+
const original = observeProcessLoss('coordinator', coordinatorId, payload);
|
|
473
|
+
const beforeObservedAt = now();
|
|
474
|
+
const startedAt = now();
|
|
475
|
+
const successor = runSuccessor('coordinator', successorId, payload, original);
|
|
476
|
+
const recoveredAt = now();
|
|
477
|
+
const recoveryObservedAt = now();
|
|
478
|
+
const path = join(scratch, 'coordinator-loss.json');
|
|
479
|
+
writeFileSync(path, JSON.stringify({ coordinatorId, successorId, original, successor }), { mode: 0o600 });
|
|
480
|
+
const observed = z.object({ coordinatorId: z.literal(coordinatorId), successorId: z.literal(successorId),
|
|
481
|
+
original: z.object({ pid: z.number().int().positive(), role: z.literal(coordinatorId), epoch: z.number().int().positive(),
|
|
482
|
+
port: z.number().int().positive(), workSha256: z.string(), lossSignal: z.literal('SIGKILL') }).passthrough(),
|
|
483
|
+
successor: z.object({ pid: z.number().int().positive(), role: z.literal(successorId), epoch: z.number().int().positive(),
|
|
484
|
+
contenderRejected: z.literal(true) }).passthrough() })
|
|
485
|
+
.strict().parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
486
|
+
return { startedAt, recoveredAt, rows: [
|
|
487
|
+
{ kind: 'coordinator_identity', phase: 'before', observedAt: beforeObservedAt, observation: {
|
|
488
|
+
coordinatorId: observed.coordinatorId, originalPid: original.pid, leadershipPort: original.port,
|
|
489
|
+
workSha256: original.workSha256, originalEpoch: original.epoch,
|
|
490
|
+
lossSignal: original.lossSignal, lossConfirmed: true,
|
|
491
|
+
} },
|
|
492
|
+
{ kind: 'leadership_recovery', phase: 'recovery', observedAt: recoveryObservedAt, observation: {
|
|
493
|
+
coordinatorId: observed.coordinatorId, successorId: observed.successorId,
|
|
494
|
+
originalPid: original.pid, successorPid: successor.pid, leadershipPort: successor.port,
|
|
495
|
+
workSha256: successor.workSha256, originalEpoch: original.epoch, successorEpoch: successor.epoch,
|
|
496
|
+
contenderRejected: successor.contenderRejected,
|
|
497
|
+
originalIsolated: true, successorReady: true, splitBrainDetected: false,
|
|
498
|
+
} },
|
|
499
|
+
] };
|
|
500
|
+
}
|
|
501
|
+
function sameSnapshot(left, right) {
|
|
502
|
+
return recoveryEvidenceSha256(left) === recoveryEvidenceSha256(right);
|
|
503
|
+
}
|
|
504
|
+
function prepareDisposableRecoveryRun(rawScenario, options) {
|
|
505
|
+
const scenario = scenarioSchema.parse(rawScenario);
|
|
506
|
+
if (!isAbsolute(options.root) || !isAbsolute(options.privateKeyFile) || !isAbsolute(options.publicKeyFile))
|
|
507
|
+
throw new Error('Recovery runner root and key paths must be absolute');
|
|
508
|
+
const root = assertSafeRoot(options.root);
|
|
509
|
+
const privateIdentity = readStablePrivateKey(options.privateKeyFile);
|
|
510
|
+
const trust = loadRecoveryRunnerTrust(options.publicKeyFile);
|
|
511
|
+
const derivedPublicKey = createPublicKey(privateIdentity.privateKey);
|
|
512
|
+
const derivedPublicKeySha256 = sha256(derivedPublicKey.export({ type: 'spki', format: 'der' }));
|
|
513
|
+
if (privateIdentity.issuer !== trust.issuer || privateIdentity.keyId !== trust.keyId
|
|
514
|
+
|| derivedPublicKeySha256 !== trust.publicKeySha256)
|
|
515
|
+
throw new Error('Recovery runner private identity does not match the configured public trust pin');
|
|
516
|
+
const now = options.now ?? (() => new Date());
|
|
517
|
+
reapOrphanedDisposableRecoveryFixtures(root);
|
|
518
|
+
const exerciseId = `${scenario}-${randomUUID()}`;
|
|
519
|
+
const fixtureRoot = mkdtempSync(join(root, 'magnus-disposable-'));
|
|
520
|
+
const targetId = `disposable:${basename(fixtureRoot)}`;
|
|
521
|
+
try {
|
|
522
|
+
writeFixtureOwner(fixtureRoot);
|
|
523
|
+
const dataRoot = join(fixtureRoot, 'data');
|
|
524
|
+
seedFixture(dataRoot);
|
|
525
|
+
const recoveryPointAt = now();
|
|
526
|
+
const before = captureSnapshot(dataRoot);
|
|
527
|
+
const beforeObservedAt = now();
|
|
528
|
+
const scenarioResult = performScenario(scenario, fixtureRoot, now);
|
|
529
|
+
const after = captureSnapshot(dataRoot);
|
|
530
|
+
const afterObservedAt = now();
|
|
531
|
+
if (!sameSnapshot(before, after))
|
|
532
|
+
throw new Error('Disposable exercise did not preserve fixture state');
|
|
533
|
+
const beforeDigests = snapshotDigests(before);
|
|
534
|
+
const afterDigests = snapshotDigests(after);
|
|
535
|
+
const scenarioSubject = recoveryEvidenceSha256({ targetId, scenario, before: beforeDigests, after: afterDigests });
|
|
536
|
+
const runtimeEvidence = [
|
|
537
|
+
evidence('target_identity', 'before', beforeObservedAt, 'control-api:disposable-runner/target', recoveryEvidenceSha256({ targetId }), { targetId, namespace: 'disposable' }),
|
|
538
|
+
evidence('state_capture', 'before', beforeObservedAt, 'control-api:disposable-runner/state', beforeDigests.stateSha256, { entryCount: before.state.length, aggregateSha256: beforeDigests.stateSha256 }),
|
|
539
|
+
evidence('task_ledger_capture', 'before', beforeObservedAt, 'control-api:disposable-runner/tasks', beforeDigests.taskSha256, { entryCount: before.taskReceipts.length, aggregateSha256: beforeDigests.taskSha256 }),
|
|
540
|
+
evidence('artifact_manifest_capture', 'before', beforeObservedAt, 'control-api:disposable-runner/artifacts', beforeDigests.artifactSha256, { entryCount: before.artifacts.length, aggregateSha256: beforeDigests.artifactSha256 }),
|
|
541
|
+
evidence('state_capture', 'after', afterObservedAt, 'control-api:disposable-runner/state', afterDigests.stateSha256, { entryCount: after.state.length, aggregateSha256: afterDigests.stateSha256 }),
|
|
542
|
+
evidence('task_ledger_capture', 'after', afterObservedAt, 'control-api:disposable-runner/tasks', afterDigests.taskSha256, { entryCount: after.taskReceipts.length, aggregateSha256: afterDigests.taskSha256 }),
|
|
543
|
+
evidence('artifact_manifest_capture', 'after', afterObservedAt, 'control-api:disposable-runner/artifacts', afterDigests.artifactSha256, { entryCount: after.artifacts.length, aggregateSha256: afterDigests.artifactSha256 }),
|
|
544
|
+
evidence('recovery_probe', 'recovery', afterObservedAt, 'control-api:disposable-runner/health', recoveryEvidenceSha256({ targetId, scenario, recoveredAt: scenarioResult.recoveredAt.toISOString() }), { targetId, scenario, ready: true, checkId: `probe-${scenario}` }),
|
|
545
|
+
...scenarioResult.rows.map((row, index) => evidence(row.kind, row.phase, row.observedAt, `control-api:disposable-runner/scenario-${index}`, scenarioSubject, row.observation)),
|
|
546
|
+
];
|
|
547
|
+
const input = recoveryExerciseCollectorSchema.parse({
|
|
548
|
+
exerciseId,
|
|
549
|
+
targetId,
|
|
550
|
+
scenario,
|
|
551
|
+
startedAt: scenarioResult.startedAt.toISOString(),
|
|
552
|
+
recoveredAt: scenarioResult.recoveredAt.toISOString(),
|
|
553
|
+
recoveryPointAt: recoveryPointAt.toISOString(),
|
|
554
|
+
objectives: { rtoSeconds: 300, rpoSeconds: 60 },
|
|
555
|
+
before,
|
|
556
|
+
after,
|
|
557
|
+
runtimeEvidence,
|
|
558
|
+
});
|
|
559
|
+
const issuedAt = now();
|
|
560
|
+
const unsigned = {
|
|
561
|
+
schemaVersion: 1,
|
|
562
|
+
issuer: trust.issuer,
|
|
563
|
+
keyId: trust.keyId,
|
|
564
|
+
nonce: `nonce-${randomBytes(24).toString('hex')}`,
|
|
565
|
+
issuedAt: issuedAt.toISOString(),
|
|
566
|
+
expiresAt: new Date(issuedAt.getTime() + 10 * 60_000).toISOString(),
|
|
567
|
+
exerciseContractSha256: recoveryExerciseContractSha256(input),
|
|
568
|
+
};
|
|
569
|
+
const receipt = {
|
|
570
|
+
...unsigned,
|
|
571
|
+
signature: sign(null, recoveryRunnerReceiptSigningBytes(unsigned), privateIdentity.privateKey).toString('base64'),
|
|
572
|
+
};
|
|
573
|
+
return { input, receipt, trust, cleanup: () => rmSync(fixtureRoot, { recursive: true, force: true }) };
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
rmSync(fixtureRoot, { recursive: true, force: true });
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Build a fully observed contract for validation without retaining its temporary fixture. */
|
|
581
|
+
export function buildDisposableRecoveryRun(rawScenario, options) {
|
|
582
|
+
const { cleanup, ...run } = prepareDisposableRecoveryRun(rawScenario, options);
|
|
583
|
+
cleanup();
|
|
584
|
+
return run;
|
|
585
|
+
}
|
|
586
|
+
export async function runDisposableRecoveryExercise(rawScenario, options, sink = recordRecoveryExerciseFromDedicatedRunner) {
|
|
587
|
+
const run = prepareDisposableRecoveryRun(rawScenario, options);
|
|
588
|
+
try {
|
|
589
|
+
return await sink(run.input, run.receipt, options.now?.() ?? new Date(), run.trust);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
run.cleanup();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
//# sourceMappingURL=disposable-recovery-runner.js.map
|