@fased/fased 0.1.61 → 0.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +38 -30
- package/config/dependency-ownership.json +1 -1
- package/config/signer-policies/README.md +49 -0
- package/config/signer-policies/agent.json.template +32 -0
- package/config/signer-policies/mining.json.template +69 -0
- package/config/signer-policies/network.json.template +5 -0
- package/config/signer-policies/vault.json.template +70 -0
- package/config/signer-protocol-v2.json +76 -0
- package/config/typescript-noemit-baseline.json +680 -0
- package/dist/acp/client.js +2 -2
- package/dist/acp/secret-file.js +2 -2
- package/dist/agents/agent-scope.js +2 -2
- package/dist/agents/apply-patch.js +8 -8
- package/dist/agents/auth-profiles/paths.js +2 -2
- package/dist/agents/auth-profiles/store.js +269 -33
- package/dist/agents/bash-tools.exec.js +10 -4
- package/dist/agents/bash-tools.shared.js +2 -2
- package/dist/agents/cli-credentials.js +4 -4
- package/dist/agents/current-model-catalog.js +3 -3
- package/dist/agents/docs-path.js +3 -3
- package/dist/agents/embedded-pi-mcp.js +4 -4
- package/dist/agents/identity-avatar.js +3 -3
- package/dist/agents/identity-file.js +2 -2
- package/dist/agents/memory-search.js +7 -1
- package/dist/agents/model-catalog-access.js +1 -2
- package/dist/agents/model-catalog-snapshot.js +1 -1
- package/dist/agents/model-catalog-source.js +1 -1
- package/dist/agents/model-metadata.js +1 -1
- package/dist/agents/model-selection.js +2 -28
- package/dist/agents/models-config.providers.js +6 -6
- package/dist/agents/openai-codex-app-server.js +5 -5
- package/dist/agents/pi-embedded-runner/compact.js +15 -7
- package/dist/agents/pi-embedded-runner/model.js +1 -1
- package/dist/agents/pi-embedded-runner/run/attempt.js +24 -13
- package/dist/agents/pi-embedded-runner/run.js +2 -0
- package/dist/agents/pi-embedded-runner/tool-split.js +40 -2
- package/dist/agents/pi-embedded-subscribe.raw-stream.js +3 -3
- package/dist/agents/pi-extensions/compaction-safeguard.js +3 -3
- package/dist/agents/pi-model-discovery.js +6 -6
- package/dist/agents/pi-project-settings.js +3 -3
- package/dist/agents/pi-tool-definition-adapter.js +16 -1
- package/dist/agents/pi-tools.js +2 -1
- package/dist/agents/sandbox/config.js +1 -1
- package/dist/agents/sandbox/context.js +27 -2
- package/dist/agents/sandbox/fs-bridge.js +2 -2
- package/dist/agents/session-dirs.js +4 -4
- package/dist/agents/session-write-lock.js +13 -13
- package/dist/agents/shell-utils.js +5 -5
- package/dist/agents/skills/bundled-dir.js +4 -4
- package/dist/agents/skills/env-overrides.js +38 -28
- package/dist/agents/skills/local-loader.js +6 -6
- package/dist/agents/skills/plugin-skills.js +2 -2
- package/dist/agents/skills/trust.js +93 -0
- package/dist/agents/skills/workspace.js +141 -13
- package/dist/agents/skills-clawhub.js +36 -11
- package/dist/agents/skills-install-download.js +7 -7
- package/dist/agents/skills-install-plan.js +2 -2
- package/dist/agents/skills-install.js +2 -2
- package/dist/agents/skills-marketplace-scan.js +49 -0
- package/dist/agents/skills-status.js +2 -2
- package/dist/agents/subagent-depth.js +2 -2
- package/dist/agents/system-prompt-params.js +2 -2
- package/dist/agents/tools/marketplace-offer-draft-tool.js +1 -1
- package/dist/agents/tools/mining-tool.js +1 -1
- package/dist/agents/tools/session-status-tool.js +1 -1
- package/dist/agents/tools/wallet-action-tool.js +172 -36
- package/dist/agents/tools/wallet-skill-policy.js +65 -23
- package/dist/agents/tools/wallet-tool.js +7 -11
- package/dist/agents/workspace.js +22 -22
- package/dist/auto-reply/reply/agent-runner-execution.js +2 -2
- package/dist/auto-reply/reply/agent-runner.js +2 -2
- package/dist/auto-reply/reply/commands-export-session.js +7 -7
- package/dist/auto-reply/reply/directive-handling.fast-lane.js +2 -1
- package/dist/auto-reply/reply/directive-handling.impl.js +2 -1
- package/dist/auto-reply/reply/directive-handling.model.js +2 -1
- package/dist/auto-reply/reply/directive-handling.persist.js +2 -2
- package/dist/auto-reply/reply/get-reply-directives-apply.js +2 -0
- package/dist/auto-reply/reply/get-reply-inline-actions.js +25 -1
- package/dist/auto-reply/reply/model-selection.js +7 -4
- package/dist/auto-reply/reply/post-compaction-audit.js +3 -3
- package/dist/auto-reply/reply/post-compaction-context.js +3 -3
- package/dist/auto-reply/reply/session-reset-model.js +4 -4
- package/dist/auto-reply/reply/session.js +3 -3
- package/dist/auto-reply/skill-commands.js +3 -3
- package/dist/auto-reply/status.js +6 -6
- package/dist/brand.js +1 -1
- package/dist/browser/chrome.executables.js +3 -3
- package/dist/browser/chrome.js +3 -3
- package/dist/browser/chrome.profile-decoration.js +6 -6
- package/dist/browser/profiles-service.js +2 -2
- package/dist/browser/server-context.js +2 -2
- package/dist/browser/trash.js +4 -4
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/dist/canvas-host/server.js +4 -4
- package/dist/capabilities/catalog.js +3 -3
- package/dist/channel-web.js +2 -2
- package/dist/channels/plugins/catalog.js +3 -3
- package/dist/cli/browser-cli-extension.js +6 -6
- package/dist/cli/command-format.js +2 -2
- package/dist/cli/cron-cli/register.cron-simple.js +2 -2
- package/dist/cli/dns-cli.js +9 -9
- package/dist/cli/gateway-cli/dev.js +5 -5
- package/dist/cli/gateway-cli/run.js +10 -2
- package/dist/cli/hooks-cli.js +5 -5
- package/dist/cli/lightweight/gateway-runtime-probe.js +2 -2
- package/dist/cli/memory-cli.js +7 -7
- package/dist/cli/nodes-media-utils.js +2 -2
- package/dist/cli/plugins-cli.js +2 -2
- package/dist/cli/program/command-registry.js +1 -1
- package/dist/cli/program/register.onboard.js +2 -3
- package/dist/cli/program/register.wallet.js +16 -123
- package/dist/cli/providers-cli.js +1 -1
- package/dist/cli/sat-cli.js +129 -2
- package/dist/cli/secrets-cli.js +3 -3
- package/dist/cli/update-cli/update-command.js +260 -11
- package/dist/cli/windows-argv.js +2 -2
- package/dist/commands/auth-choice.apply.api-providers.js +3 -3
- package/dist/commands/dashboard-link.js +3 -3
- package/dist/commands/doctor-config-flow.js +1 -1
- package/dist/commands/doctor-install.js +5 -5
- package/dist/commands/doctor-legacy-config.js +2 -13
- package/dist/commands/doctor-memory-search.js +2 -2
- package/dist/commands/doctor-platform-notes.js +2 -2
- package/dist/commands/doctor-sandbox.js +2 -2
- package/dist/commands/doctor-state-integrity.js +23 -23
- package/dist/commands/doctor-workspace.js +3 -3
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/managed-up.js +2 -2
- package/dist/commands/model-picker.js +6 -6
- package/dist/commands/models/auth.js +1 -1
- package/dist/commands/onboard-auth.config-gateways.js +1 -1
- package/dist/commands/onboard-auth.credentials.js +35 -11
- package/dist/commands/onboard-non-interactive/local/wallet-config.js +28 -27
- package/dist/commands/onboard-non-interactive/local.js +5 -1
- package/dist/commands/onboarding/plugin-install.js +2 -2
- package/dist/commands/sessions-cleanup.js +2 -2
- package/dist/commands/signal-install.js +6 -6
- package/dist/commands/status-all/channels.js +2 -2
- package/dist/commands/wallet.js +570 -1913
- package/dist/config/cache-utils.js +2 -2
- package/dist/config/config.js +2 -2
- package/dist/config/includes.js +5 -5
- package/dist/config/io.js +66 -4
- package/dist/config/paths.js +3 -3
- package/dist/config/sat-runtime-ids.js +3 -3
- package/dist/config/schema.help.js +8 -7
- package/dist/config/schema.labels.js +5 -4
- package/dist/config/sessions/disk-budget.js +6 -6
- package/dist/config/sessions/paths.js +2 -2
- package/dist/config/sessions/store.js +17 -17
- package/dist/config/sessions/transcript.js +4 -4
- package/dist/config/talk.js +2 -2
- package/dist/config/zod-schema.agent-runtime.js +1 -0
- package/dist/config/zod-schema.js +5 -0
- package/dist/control-ui/assets/{app-BXA4gPvN.js → app-bR-fO5ty.js} +1511 -1555
- package/dist/control-ui/assets/{config-CU5Zi8Dh.js → config-LeImukCW.js} +10 -10
- package/dist/control-ui/assets/{federation-D10Q4eP_.js → federation-DgA9e-Pn.js} +364 -422
- package/dist/control-ui/assets/{index-D2QbJhVg.js → index-ckCxHwjy.js} +3 -3
- package/dist/control-ui/assets/{mining-BdhMn18r.js → mining-BgHFh0ei.js} +134 -114
- package/dist/control-ui/assets/mining-commit-DFpB_4JN.js +2 -0
- package/dist/control-ui/assets/{open-external-url-DwSiwMPl.js → open-external-url-ulF1B-sA.js} +3 -3
- package/dist/control-ui/assets/{providers-QMn4w2_f.js → providers-CIx9fxc6.js} +2 -2
- package/dist/control-ui/assets/{wallet-C2g294QT.js → wallet-DvStR_AT.js} +259 -377
- package/dist/control-ui/index.html +1 -1
- package/dist/control-ui/version.json +1 -1
- package/dist/cron/service/store.js +2 -2
- package/dist/cron/store.js +6 -6
- package/dist/daemon/systemd-system.js +60 -54
- package/dist/discord/monitor/thread-bindings.state.js +2 -2
- package/dist/extensions/sat-mining/src/protocol-contract.js +43 -3
- package/dist/extensions/sat-mining/src/rpc-read.js +235 -67
- package/dist/extensions/sat-mining/src/sat-bond-layout.js +14 -11
- package/dist/extensions/sat-mining/src/signer-codec-manifest.js +406 -0
- package/dist/extensions/sat-mining/src/solana-submit.js +1038 -397
- package/dist/extensions/sat-mining/src/submission-ledger.js +349 -0
- package/dist/federation/auto-connect.js +98 -8
- package/dist/federation/marketplace-capability-adapter.js +33 -7
- package/dist/federation/marketplace-delivery-outbox.js +196 -0
- package/dist/federation/marketplace-delivery.js +351 -215
- package/dist/federation/marketplace-direct-payment.js +272 -141
- package/dist/federation/marketplace-escrow.js +607 -249
- package/dist/federation/marketplace-settlement-ledger.js +352 -0
- package/dist/federation/offers.js +9 -7
- package/dist/federation/peer-auth-v2.js +529 -0
- package/dist/gateway/a2a-http.js +425 -96
- package/dist/gateway/a2a-settlement.js +259 -153
- package/dist/gateway/a2a-task-store.js +524 -0
- package/dist/gateway/auth.js +38 -2
- package/dist/gateway/config-reload.js +46 -33
- package/dist/gateway/control-ui-boot-check.js +6 -6
- package/dist/gateway/control-ui-login.js +7 -7
- package/dist/gateway/control-ui.js +9 -9
- package/dist/gateway/durable-capacity.js +31 -0
- package/dist/gateway/federation-http.js +540 -135
- package/dist/gateway/federation-marketplace-run-store.js +397 -0
- package/dist/gateway/federation-marketplace.js +535 -230
- package/dist/gateway/hooks-mapping.js +4 -4
- package/dist/gateway/mutating-admin-rpc-rate-limit.js +5 -0
- package/dist/gateway/security-path.js +1 -1
- package/dist/gateway/server-discovery.js +2 -2
- package/dist/gateway/server-http.js +1088 -1209
- package/dist/gateway/server-methods/chat.js +3 -3
- package/dist/gateway/server-methods/models.js +1 -1
- package/dist/gateway/server-methods/secrets.js +40 -0
- package/dist/gateway/server-methods/sessions.js +6 -6
- package/dist/gateway/server-methods/usage.js +2 -2
- package/dist/gateway/server-methods.js +1 -2
- package/dist/gateway/server-reload-handlers.js +196 -66
- package/dist/gateway/server-runtime-config.js +1 -1
- package/dist/gateway/server-secrets-runtime.js +288 -0
- package/dist/gateway/server.impl.js +73 -42
- package/dist/gateway/session-compaction-checkpoints.js +4 -4
- package/dist/gateway/session-utils.fs.js +24 -24
- package/dist/gateway/session-utils.js +6 -6
- package/dist/gateway/sessions-patch.js +3 -5
- package/dist/hooks/bundled-dir.js +4 -4
- package/dist/hooks/gmail-setup-utils.js +6 -6
- package/dist/hooks/import-url.js +2 -2
- package/dist/hooks/loader.js +4 -4
- package/dist/hooks/workspace.js +7 -7
- package/dist/index.js +4 -2
- package/dist/infra/archive.js +17 -17
- package/dist/infra/boundary-file-read.js +2 -2
- package/dist/infra/boundary-path.js +15 -15
- package/dist/infra/brew.js +2 -2
- package/dist/infra/control-ui-assets.js +14 -14
- package/dist/infra/device-auth-store.js +8 -8
- package/dist/infra/device-identity.js +8 -8
- package/dist/infra/dotenv.js +2 -2
- package/dist/infra/exec-approvals.js +8 -8
- package/dist/infra/exec-command-resolution.js +4 -4
- package/dist/infra/exec-safe-bin-trust.js +2 -2
- package/dist/infra/fased-root.js +5 -5
- package/dist/infra/fasedhub.js +64 -1
- package/dist/infra/fs-safe.js +14 -14
- package/dist/infra/gateway-lock.js +11 -11
- package/dist/infra/git-commit.js +5 -5
- package/dist/infra/git-root.js +4 -4
- package/dist/infra/hosted-runtime-artifact.js +3 -3
- package/dist/infra/is-main.js +2 -2
- package/dist/infra/json-file.js +7 -7
- package/dist/infra/local-source-paired-update.js +538 -0
- package/dist/infra/managed-runtime-bootstrap.js +6 -6
- package/dist/infra/outbound/delivery-queue.js +16 -16
- package/dist/infra/path-env.js +3 -3
- package/dist/infra/ports-lsof.js +4 -4
- package/dist/infra/provider-usage.auth.js +3 -3
- package/dist/infra/restart.js +41 -10
- package/dist/infra/safe-open-sync.js +2 -2
- package/dist/infra/session-cost-usage.js +9 -9
- package/dist/infra/shell-env.js +2 -2
- package/dist/infra/state-migrations.fs.js +6 -6
- package/dist/infra/state-migrations.js +23 -23
- package/dist/infra/tmp-fased-dir.js +6 -6
- package/dist/infra/update-runner.js +96 -0
- package/dist/infra/widearea-dns.js +3 -3
- package/dist/infra/wsl.js +2 -2
- package/dist/light-update-status.js +11 -11
- package/dist/line/accounts.js +2 -2
- package/dist/line/download.js +2 -2
- package/dist/logging/config.js +3 -3
- package/dist/logging/logger.js +7 -7
- package/dist/managed/federation.js +3 -3
- package/dist/managed/tunnel.js +3 -3
- package/dist/media/store.js +13 -13
- package/dist/media-understanding/runner.js +3 -3
- package/dist/memory/backend-config.js +2 -1
- package/dist/memory/embeddings.js +2 -2
- package/dist/memory/internal.js +10 -10
- package/dist/memory/manager-sync-ops.js +7 -7
- package/dist/memory/repair-executor.js +17 -17
- package/dist/memory/session-files.js +16 -2
- package/dist/node-host/invoke-system-run-plan.js +8 -8
- package/dist/node-host/invoke.js +2 -2
- package/dist/node-host/runner.js +3 -3
- package/dist/pairing/pairing-store.js +3 -3
- package/dist/plugin-sdk/{accounts-DxblobYM.js → accounts-Brn3-uWN.js} +1 -1
- package/dist/plugin-sdk/{actions-D4UNimWa.js → actions-DRu135h0.js} +2 -2
- package/dist/plugin-sdk/{active-listener-CFuy106s.js → active-listener-B_NMtgPL.js} +1 -1
- package/dist/plugin-sdk/{audio-preflight-Bv0hUOWL.js → audio-preflight-BYxAt775.js} +27 -27
- package/dist/plugin-sdk/{audit-Cs0K7LBp.js → audit-C5d2z5U1.js} +1 -1
- package/dist/plugin-sdk/{channel-access-D5aHoTY8.js → channel-access-CcFRdRPO.js} +1 -1
- package/dist/plugin-sdk/{channel-access-configure-Dvch9mMQ.js → channel-access-configure-DaI5wevh.js} +1 -1
- package/dist/plugin-sdk/channel-plugin-common.d.ts +3 -3
- package/dist/plugin-sdk/channel-plugin-common.js +4 -4
- package/dist/plugin-sdk/{chunk-B0FUGc5A.js → chunk-CyoyqpbG.js} +1 -1
- package/dist/plugin-sdk/{chunk-items-Bk--NU3E.js → chunk-items-DIRHG2gJ.js} +1 -1
- package/dist/plugin-sdk/command-status.d.ts +2 -2
- package/dist/plugin-sdk/command-status.js +27 -27
- package/dist/plugin-sdk/{commands-registry-DmDRhRAU.js → commands-registry-BfqdEWdF.js} +2 -2
- package/dist/plugin-sdk/{common-CfUOowOp.js → common-HCcXU-NR.js} +1 -1
- package/dist/plugin-sdk/{config-mxULv0fQ.js → config-DlWVeLFB.js} +7113 -6877
- package/dist/plugin-sdk/{config-schema-CGW6J3Pv.js → config-schema-BOT7kHb6.js} +6779 -3696
- package/dist/plugin-sdk/device-pair.d.ts +1 -1
- package/dist/plugin-sdk/discord.d.ts +5 -5
- package/dist/plugin-sdk/discord.js +19 -19
- package/dist/plugin-sdk/file-lock.js +35 -4
- package/dist/plugin-sdk/{filesystem-manager-ZoHU8oA7.js → filesystem-manager-DoI8IBNF.js} +1 -1
- package/dist/plugin-sdk/{group-mentions-BHRff6j8.d.ts → group-mentions-q3hUTCt7.d.ts} +1 -1
- package/dist/plugin-sdk/{handle-action-DbVCF9HZ.js → handle-action-hvwapMKg.js} +13 -13
- package/dist/plugin-sdk/{helpers-BXbdgs5H.js → helpers-DMBU4oTo.js} +1 -1
- package/dist/plugin-sdk/{image-ops-D7jPxaph.js → image-ops-DQi0n_Tt.js} +1 -1
- package/dist/plugin-sdk/index.d.ts +9 -27
- package/dist/plugin-sdk/index.js +29 -29
- package/dist/plugin-sdk/json-store.js +6 -6
- package/dist/plugin-sdk/{login-BRxww6nv.js → login-Ca583Okc.js} +3 -3
- package/dist/plugin-sdk/{login-qr-DJXsk9XH.js → login-qr-CGciBSNP.js} +3 -3
- package/dist/plugin-sdk/{manager-Y2S_5IlI.js → manager-BegWxNYX.js} +4 -4
- package/dist/plugin-sdk/{markdown-tables-DHLFQqaF.js → markdown-tables-DCOXEUfZ.js} +1 -1
- package/dist/plugin-sdk/{media-Czo4carg.js → media-CAdQF5TG.js} +47 -47
- package/dist/plugin-sdk/{memory-search-DTjIMw8J.js → memory-search-CrFPOeTH.js} +4 -1
- package/dist/plugin-sdk/{monitor-B9Cmt_Je.js → monitor-BCFDaqJ-.js} +31 -31
- package/dist/plugin-sdk/{monitor-B71iwAgC.js → monitor-BgbjRiPM.js} +32 -32
- package/dist/plugin-sdk/{openresponses-http-Du4GMmwo.js → openresponses-http-DFf87SMM.js} +27 -27
- package/dist/plugin-sdk/{optional-runtime-dependency-DdMbxS24.js → optional-runtime-dependency-0dznm3ZO.js} +1 -1
- package/dist/plugin-sdk/{outbound-Db8mRN6P.js → outbound-BgVDdHxx.js} +6 -6
- package/dist/plugin-sdk/{outbound-attachment-BqFjcypb.js → outbound-attachment-QqTnIhZL.js} +2 -2
- package/dist/plugin-sdk/{pairing-message-BMkkS038.d.ts → pairing-message-CS-9BHug.d.ts} +2 -2
- package/dist/plugin-sdk/{pairing-message-B3GC_7CD.js → pairing-message-D0CoLHRJ.js} +1 -1
- package/dist/plugin-sdk/{pairing-store-CQikgIbw.js → pairing-store-BChahgI2.js} +2 -2
- package/dist/plugin-sdk/{pi-model-discovery-CY3m5zJP.js → pi-model-discovery-BX_JLcEJ.js} +1 -1
- package/dist/plugin-sdk/{pi-model-discovery-runtime-DSh1T2pn.js → pi-model-discovery-runtime-DxTMz_XF.js} +2 -2
- package/dist/plugin-sdk/provider-web-search-config-contract.d.ts +2 -2
- package/dist/plugin-sdk/{pw-ai-AFUqn-tQ.js → pw-ai-natjh3rm.js} +3 -3
- package/dist/plugin-sdk/{qmd-manager-DSyx77KY.js → qmd-manager-CgRaTxUq.js} +1 -1
- package/dist/plugin-sdk/{query-expansion-CAI2kKLQ.js → query-expansion-BG3FgzF7.js} +14 -3
- package/dist/plugin-sdk/{replies-Fzh9PhXl.js → replies-ChYo5ZQM.js} +2 -2
- package/dist/plugin-sdk/{resolve-outbound-target-KpF3co6T.js → resolve-outbound-target-D-KGgHJZ.js} +1 -1
- package/dist/plugin-sdk/{resolve-route-CDl74O19.js → resolve-route-C8tvQm8Z.js} +1 -1
- package/dist/plugin-sdk/{retry-policy-D4uiqUOG.js → retry-policy-C6SjKjQA.js} +1 -1
- package/dist/plugin-sdk/{runtime-Bb6KRnCC.js → runtime-2B9_WKoI.js} +8 -3
- package/dist/plugin-sdk/{runtime-dependency-DLDmB94f.js → runtime-dependency-DczkvPxn.js} +2 -2
- package/dist/plugin-sdk/sat-runtime.d.ts +549 -309
- package/dist/plugin-sdk/sat-runtime.js +29 -29
- package/dist/plugin-sdk/{send-C4k4JuJN.js → send-B6KkGKZD.js} +6 -6
- package/dist/plugin-sdk/{send-D-uDKGIq.js → send-CFzGKJXo.js} +6 -6
- package/dist/plugin-sdk/{send-Du8ykP1k.js → send-DCO441U7.js} +8 -8
- package/dist/plugin-sdk/{send-5nFktR1n.js → send-gDoGA-7e.js} +5 -5
- package/dist/plugin-sdk/{send-Bfzf11VY.js → send-q9HXrSwK.js} +7 -7
- package/dist/plugin-sdk/{session-CS-Td9EM.js → session-DHD4ZKyG.js} +2 -2
- package/dist/plugin-sdk/{skill-commands-CFAlXWHk.js → skill-commands-FKEPLS7T.js} +201 -26
- package/dist/plugin-sdk/{slack-D_bbE6Yd.js → slack-BUTt8cxK.js} +39 -39
- package/dist/plugin-sdk/{slack-actions-BcMkNv1Z.js → slack-actions-DhbMcp8M.js} +11 -11
- package/dist/plugin-sdk/slack.d.ts +5 -5
- package/dist/plugin-sdk/slack.js +9 -9
- package/dist/plugin-sdk/{sticker-cache-Dqo9nRhi.js → sticker-cache-D0wHVqjt.js} +3 -3
- package/dist/plugin-sdk/{tables-bFXyRpkx.js → tables-B3jr0Pvq.js} +1 -1
- package/dist/plugin-sdk/{targets-DH7a9lKB.js → targets-CtSbTxYX.js} +1 -1
- package/dist/plugin-sdk/{telegram-actions-Cnag7JTH.js → telegram-actions-CC_qN4v0.js} +30 -30
- package/dist/plugin-sdk/telegram.d.ts +5 -5
- package/dist/plugin-sdk/telegram.js +8 -8
- package/dist/plugin-sdk/{thread-bindings.lifecycle-CgOvvdw8.js → thread-bindings.lifecycle-QbkNl-m6.js} +3 -3
- package/dist/plugin-sdk/{thread-bindings.state-CQpk44Hq.js → thread-bindings.state-D9hg_M1c.js} +2 -2
- package/dist/plugin-sdk/tmp-fased-dir-xOTGlzBE.d.ts +486 -0
- package/dist/plugin-sdk/{types-CLIvQaVg.d.ts → types-C3avNytw.d.ts} +3 -4
- package/dist/plugin-sdk/{types-DyhXnYUr.d.ts → types-D0kDG8l0.d.ts} +3 -2
- package/dist/plugin-sdk/{types-BYDL6dQj.d.ts → types-DWfbSAaM.d.ts} +2 -2
- package/dist/plugin-sdk/{types.plugin-_fbu40Re.d.ts → types.plugin-BcSBq42x.d.ts} +8 -3
- package/dist/plugin-sdk/{web-BInKQkc4.js → web-BXsJ1vqe.js} +32 -32
- package/dist/plugin-sdk/{whatsapp-actions-BtSc2CBv.js → whatsapp-actions-Bj_rOncr.js} +13 -13
- package/dist/plugin-sdk/{whatsapp-heartbeat-Bg9TuYzl.js → whatsapp-heartbeat-B1WHkJIg.js} +2 -2
- package/dist/plugin-sdk/whatsapp.d.ts +5 -5
- package/dist/plugin-sdk/whatsapp.js +12 -12
- package/dist/plugin-sdk/{wsl-C4DGFkOd.js → wsl-J-HscWxS.js} +3 -3
- package/dist/plugins/bundled-dir.js +3 -3
- package/dist/plugins/discovery.js +11 -11
- package/dist/plugins/loader.js +13 -13
- package/dist/plugins/manifest-registry.js +2 -2
- package/dist/plugins/manifest.js +4 -4
- package/dist/plugins/path-safety.js +3 -3
- package/dist/plugins/status-cache.js +9 -9
- package/dist/plugins/update.js +3 -3
- package/dist/plugins/web-search-install-catalog.js +3 -3
- package/dist/providers/refresh.js +1 -1
- package/dist/providers/registry.js +1 -1
- package/dist/secrets/apply.js +17 -17
- package/dist/secrets/audit.js +11 -11
- package/dist/secrets/runtime.js +1 -1
- package/dist/secrets/shared.js +9 -9
- package/dist/security/scan-paths.js +2 -2
- package/dist/shared/config-eval.js +2 -2
- package/dist/shared/pid-alive.js +2 -2
- package/dist/tasks/standing-orders.js +5 -5
- package/dist/tasks/task-flow-registry.js +5 -5
- package/dist/tasks/task-registry.js +5 -5
- package/dist/tasks/workflow-definitions.js +6 -6
- package/dist/telegram/token.js +5 -5
- package/dist/utils.js +5 -5
- package/dist/wallet/external-submission-ledger.js +376 -0
- package/dist/wallet/legacy-embedded-keystore.js +40 -0
- package/dist/wallet/local-socket-signer-archive.js +30 -0
- package/dist/wallet/local-socket-signer-lifecycle.js +97 -0
- package/dist/wallet/local-socket-signer-policy.js +166 -0
- package/dist/wallet/local-socket-signer-protocol.js +561 -118
- package/dist/wallet/native-signer-wallet-id.js +25 -0
- package/dist/wallet/providers/alchemy-adapter.js +3 -0
- package/dist/wallet/providers/local-socket-signer-adapter.js +382 -107
- package/dist/wallet/providers/turnkey-adapter.js +292 -34
- package/dist/wallet/providers/wallet-standard-adapter.js +100 -0
- package/dist/wallet/signer-network-admin.js +184 -0
- package/dist/wallet/signer-protocol-v2.generated.js +90 -0
- package/dist/wallet/solana-bond-signing.js +157 -69
- package/dist/wallet/solana-reviewed-transaction.js +375 -0
- package/dist/wallet/solana-swap.js +520 -64
- package/dist/wallet/solana-transaction-inspection.js +106 -4
- package/dist/wallet/solana-trigger.js +658 -320
- package/dist/wallet/turnkey-reviewed-state.js +370 -0
- package/dist/wallet/wallet-approval-auth.js +20 -31
- package/dist/wallet/wallet-atomic-state.js +53 -0
- package/dist/wallet/wallet-audit-log.js +5 -5
- package/dist/wallet/wallet-canary.js +0 -1
- package/dist/wallet/wallet-inbound-events.js +19 -14
- package/dist/wallet/wallet-observability.js +5 -5
- package/dist/wallet/wallet-policy-simulation.js +1 -1
- package/dist/wallet/wallet-policy.js +95 -41
- package/dist/wallet/wallet-provider-capabilities.js +28 -4
- package/dist/wallet/wallet-provider-registry.js +64 -51
- package/dist/wallet/wallet-provider-resolver.js +62 -63
- package/dist/wallet/wallet-runtime-config.js +31 -44
- package/dist/wallet/wallet-secrets-store.js +44 -22
- package/dist/wallet/wallet-send-approvals.js +1894 -592
- package/dist/wallet/wallet-send-execution-ledger.js +292 -0
- package/dist/wallet/wallet-settlement-links.js +17 -16
- package/dist/wallet/wallet-standard-review.js +353 -0
- package/dist/wallet/wallet-status.js +0 -9
- package/dist/wallet/wallet-task-ledger.js +16 -2
- package/dist/web/accounts.js +3 -3
- package/dist/web/auth-store.js +18 -18
- package/dist/web/auto-reply/heartbeat-runner.js +1 -1
- package/dist/web/auto-reply/monitor.js +2 -2
- package/dist/web/session.js +4 -4
- package/dist/wizard/host-security-capability.js +15 -24
- package/dist/wizard/onboarding.finalize.js +63 -184
- package/dist/wizard/onboarding.host-security.js +118 -1368
- package/dist/wizard/onboarding.js +180 -242
- package/dist/wizard/onboarding.wallet.js +323 -829
- package/extensions/acpx/package.json +1 -1
- package/extensions/bluebubbles/package.json +1 -1
- package/extensions/copilot-proxy/package.json +1 -1
- package/extensions/diagnostics-otel/package.json +1 -1
- package/extensions/fased-federation/package.json +1 -1
- package/extensions/google-gemini-cli-auth/package.json +1 -1
- package/extensions/imessage/package.json +1 -1
- package/extensions/irc/package.json +1 -1
- package/extensions/line/package.json +1 -1
- package/extensions/llm-task/package.json +1 -1
- package/extensions/lobster/package.json +1 -1
- package/extensions/matrix/CHANGELOG.md +18 -0
- package/extensions/matrix/package.json +1 -1
- package/extensions/mattermost/package.json +1 -1
- package/extensions/memory-core/package.json +2 -2
- package/extensions/memory-lancedb/package.json +1 -1
- package/extensions/minimax-portal-auth/package.json +1 -1
- package/extensions/msteams/CHANGELOG.md +18 -0
- package/extensions/msteams/package.json +1 -1
- package/extensions/nextcloud-talk/package.json +1 -1
- package/extensions/nostr/CHANGELOG.md +18 -0
- package/extensions/nostr/package.json +1 -1
- package/extensions/open-prose/package.json +1 -1
- package/extensions/sat-mining/index.ts +498 -153
- package/extensions/sat-mining/package.json +1 -1
- package/extensions/sat-mining/signer-codec-schema.v1.json +374 -0
- package/extensions/sat-mining/src/audit-store.ts +50 -5
- package/extensions/sat-mining/src/claim-service.ts +85 -14
- package/extensions/sat-mining/src/config.ts +9 -0
- package/extensions/sat-mining/src/cycle-progress.ts +16 -8
- package/extensions/sat-mining/src/epoch-service.ts +9 -5
- package/extensions/sat-mining/src/gateway-runner.ts +21 -1
- package/extensions/sat-mining/src/protocol-contract.ts +45 -1
- package/extensions/sat-mining/src/recovery-service.ts +312 -0
- package/extensions/sat-mining/src/round-watcher.ts +391 -81
- package/extensions/sat-mining/src/rpc-read.ts +330 -89
- package/extensions/sat-mining/src/runtime.ts +14 -0
- package/extensions/sat-mining/src/sat-bond-layout.ts +6 -0
- package/extensions/sat-mining/src/signer-codec-manifest.ts +429 -0
- package/extensions/sat-mining/src/solana-submit.ts +1199 -428
- package/extensions/sat-mining/src/submission-ledger.ts +433 -0
- package/extensions/signal/package.json +1 -1
- package/extensions/synology-chat/package.json +1 -1
- package/extensions/tlon/package.json +1 -1
- package/extensions/twitch/CHANGELOG.md +18 -0
- package/extensions/twitch/package.json +1 -1
- package/extensions/voice-call/CHANGELOG.md +18 -0
- package/extensions/voice-call/package.json +1 -1
- package/extensions/zalo/CHANGELOG.md +18 -0
- package/extensions/zalo/package.json +1 -1
- package/extensions/zalouser/CHANGELOG.md +18 -0
- package/extensions/zalouser/package.json +1 -1
- package/install.sh +4955 -0
- package/package.json +24 -5
- package/scripts/fased-host-updater.mjs +1421 -0
- package/scripts/fased-host-updaterctl.mjs +186 -0
- package/scripts/fased-managed-service.sh +36 -0
- package/scripts/fased-managed-updater.mjs +3065 -156
- package/scripts/fased-signer-enroll-hosting.sh +275 -0
- package/scripts/fased-signer-network-hosting.sh +143 -0
- package/scripts/fased-signer-owner-policy.mjs +1430 -0
- package/scripts/fased-signer-policy-hosting.sh +50 -0
- package/scripts/fased-signer-policy-local.sh +26 -0
- package/scripts/hosted-release-manifest.mjs +185 -0
- package/scripts/install-fased-signerd.sh +217 -0
- package/scripts/install-managed-runtime.mjs +212 -10
- package/scripts/managed-runtime-layout.mjs +75 -3
- package/scripts/migrate-hosted-signer-v2.mjs +58 -0
- package/scripts/start-managed.sh +169 -360
- package/dist/control-ui/assets/mining-commit-R-6nu87p.js +0 -2
- package/dist/plugin-sdk/tmp-fased-dir-BdFQvrvw.d.ts +0 -77
- package/dist/wallet/local-socket-signer-broker.js +0 -224
- package/dist/wallet/local-socket-signer-custody.js +0 -42
- package/dist/wallet/providers/embedded-keystore-adapter.js +0 -402
- package/dist/wallet/providers/privy-adapter.js +0 -80
- package/dist/wallet/wallet-custody.js +0 -1836
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import fsp from "node:fs/promises";
|
|
6
6
|
import http from "node:http";
|
|
7
7
|
import https from "node:https";
|
|
8
|
+
import net from "node:net";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { Readable } from "node:stream";
|
|
11
12
|
import { pipeline } from "node:stream/promises";
|
|
12
|
-
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
14
|
import { promisify } from "node:util";
|
|
15
|
+
import { readHostedReleaseManifestV2, verifyManifestArtifact } from "./hosted-release-manifest.mjs";
|
|
14
16
|
import {
|
|
15
17
|
assertManagedRuntime,
|
|
16
18
|
atomicSymlink,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
buildManagedInstallManifest,
|
|
19
21
|
copyExecutable,
|
|
20
22
|
readHostedRuntimeMetadata,
|
|
23
|
+
readHostedReleaseBinding,
|
|
21
24
|
readManagedInstallManifest,
|
|
22
25
|
readPackageVersion,
|
|
23
26
|
resolveLinkTarget,
|
|
@@ -28,6 +31,53 @@ const execFileAsync = promisify(execFile);
|
|
|
28
31
|
const DEFAULT_RELEASE_BASE_URL = "https://github.com/fased-ai/fased/releases/download";
|
|
29
32
|
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
30
33
|
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
|
34
|
+
const RELEASE_REPOSITORY = "fased-ai/fased";
|
|
35
|
+
const RELEASE_WORKFLOW = "fased-ai/fased/.github/workflows/hosted-runtime-release.yml";
|
|
36
|
+
const HOST_UPDATER_SOCKET = "/run/fased-host-updater/request.sock";
|
|
37
|
+
const HOST_UPDATER_SCHEMA_VERSION = 2;
|
|
38
|
+
const HOSTED_TRANSACTION_SCHEMA_VERSION = 1;
|
|
39
|
+
const HOSTED_TRANSACTION_PHASES = new Set([
|
|
40
|
+
"prepared",
|
|
41
|
+
"quiescing",
|
|
42
|
+
"signer-preactivated",
|
|
43
|
+
"app-active",
|
|
44
|
+
"signer-active",
|
|
45
|
+
"gateway-verified",
|
|
46
|
+
"rolling-back",
|
|
47
|
+
"rollback-ready",
|
|
48
|
+
]);
|
|
49
|
+
const TRANSACTION_ID_PATTERN =
|
|
50
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
51
|
+
const LOCAL_SIGNER_TRANSACTION_SCHEMA_VERSION = 1;
|
|
52
|
+
const LOCAL_SIGNER_TRANSACTION_PHASES = new Set([
|
|
53
|
+
"staging",
|
|
54
|
+
"quiesced",
|
|
55
|
+
"snapshotted",
|
|
56
|
+
"prepared",
|
|
57
|
+
"activating",
|
|
58
|
+
"candidate-active",
|
|
59
|
+
"committing",
|
|
60
|
+
"rolling-back",
|
|
61
|
+
]);
|
|
62
|
+
const LOCAL_SIGNER_REQUIRED_FEATURES = [
|
|
63
|
+
"failClosedPolicies",
|
|
64
|
+
"policyHashes",
|
|
65
|
+
"durableCaps",
|
|
66
|
+
"atomicIdempotency",
|
|
67
|
+
"ambiguousBroadcastReconciliation",
|
|
68
|
+
"signerOwnedKeys",
|
|
69
|
+
"typedSolanaTransactions",
|
|
70
|
+
"atomicMultiAssetCaps",
|
|
71
|
+
"signerControlledNativeFeeCaps",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export const PRE_V2_HOSTING_MIGRATION_MESSAGE = [
|
|
75
|
+
"This hosted installation needs the one-time signer-v2 security migration before it can update.",
|
|
76
|
+
"From a VPS provider console or a root SSH session, run:",
|
|
77
|
+
"Follow the verified release-asset and GitHub attestation procedure at https://docs.fased.ai/install/vps#3-install-fased-and-connect-through-tailscale, then run the verified tagged install.sh with --repair-hosting --release vX.Y.Z.",
|
|
78
|
+
"Never run /home/app/fased/install.sh with sudo or as root.",
|
|
79
|
+
"The current Gateway, signer, wallets, and persistent state were left unchanged.",
|
|
80
|
+
].join(" ");
|
|
31
81
|
|
|
32
82
|
async function measureStage(timings, name, operation) {
|
|
33
83
|
const startedAt = Date.now();
|
|
@@ -250,7 +300,50 @@ async function downloadToFile(url, destination, timeoutMs) {
|
|
|
250
300
|
);
|
|
251
301
|
}
|
|
252
302
|
|
|
253
|
-
async function
|
|
303
|
+
async function verifyOfficialAsset(assetPath, version, timeoutMs) {
|
|
304
|
+
const gh = ["/usr/bin/gh", "/usr/local/bin/gh", "/opt/homebrew/bin/gh"].find((candidate) =>
|
|
305
|
+
fs.existsSync(candidate),
|
|
306
|
+
);
|
|
307
|
+
if (!gh) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
"GitHub CLI with `gh attestation verify` is required for exact tagged Fased release assets.",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const result = await runFile(
|
|
313
|
+
gh,
|
|
314
|
+
[
|
|
315
|
+
"attestation",
|
|
316
|
+
"verify",
|
|
317
|
+
assetPath,
|
|
318
|
+
"--repo",
|
|
319
|
+
RELEASE_REPOSITORY,
|
|
320
|
+
"--signer-workflow",
|
|
321
|
+
RELEASE_WORKFLOW,
|
|
322
|
+
"--source-ref",
|
|
323
|
+
`refs/tags/v${version}`,
|
|
324
|
+
"--deny-self-hosted-runners",
|
|
325
|
+
],
|
|
326
|
+
{
|
|
327
|
+
env: {
|
|
328
|
+
HOME: process.env.HOME,
|
|
329
|
+
PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
|
|
330
|
+
GH_PROMPT_DISABLED: "1",
|
|
331
|
+
},
|
|
332
|
+
timeoutMs,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
if (!result.ok) {
|
|
336
|
+
throw new Error(`Release attestation verification failed: ${result.stderr.trim()}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function downloadVerifiedAsset({
|
|
341
|
+
releaseUrl,
|
|
342
|
+
assetName,
|
|
343
|
+
destinationDir,
|
|
344
|
+
timeoutMs,
|
|
345
|
+
officialVersion,
|
|
346
|
+
}) {
|
|
254
347
|
const checksum = await fetchText(`${releaseUrl}/${assetName}.sha256`, timeoutMs);
|
|
255
348
|
const expected = checksum
|
|
256
349
|
.split(/\r?\n/)
|
|
@@ -266,6 +359,25 @@ async function downloadVerifiedAsset({ releaseUrl, assetName, destinationDir, ti
|
|
|
266
359
|
if (actual !== expected) {
|
|
267
360
|
throw new Error(`Checksum mismatch for ${assetName}.`);
|
|
268
361
|
}
|
|
362
|
+
if (officialVersion) {
|
|
363
|
+
await verifyOfficialAsset(destination, officialVersion, timeoutMs);
|
|
364
|
+
}
|
|
365
|
+
return destination;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function downloadManifestBoundAsset({
|
|
369
|
+
releaseUrl,
|
|
370
|
+
artifact,
|
|
371
|
+
destinationDir,
|
|
372
|
+
timeoutMs,
|
|
373
|
+
officialVersion,
|
|
374
|
+
}) {
|
|
375
|
+
const destination = path.join(destinationDir, artifact.asset);
|
|
376
|
+
await downloadToFile(`${releaseUrl}/${artifact.asset}`, destination, timeoutMs);
|
|
377
|
+
await verifyManifestArtifact(destination, artifact);
|
|
378
|
+
if (officialVersion) {
|
|
379
|
+
await verifyOfficialAsset(destination, officialVersion, timeoutMs);
|
|
380
|
+
}
|
|
269
381
|
return destination;
|
|
270
382
|
}
|
|
271
383
|
|
|
@@ -328,32 +440,60 @@ async function ensureDependencyLayer({
|
|
|
328
440
|
paths,
|
|
329
441
|
temporaryRoot,
|
|
330
442
|
timeoutMs,
|
|
443
|
+
officialVersion,
|
|
444
|
+
manifestArtifact,
|
|
331
445
|
}) {
|
|
332
446
|
const dependencyRoot = path.join(
|
|
333
447
|
paths.stateDir,
|
|
334
448
|
"install-cache",
|
|
335
449
|
"hosted-dependencies",
|
|
336
|
-
dependencyHash,
|
|
450
|
+
manifestArtifact ? `${dependencyHash}-${manifestArtifact.sha256}` : dependencyHash,
|
|
337
451
|
);
|
|
338
452
|
const nodeModules = path.join(dependencyRoot, "node_modules");
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
453
|
+
const durabilityMarker = path.join(dependencyRoot, ".fased-durable-v1");
|
|
454
|
+
const dependencyExists = await fsp
|
|
455
|
+
.access(nodeModules)
|
|
456
|
+
.then(() => true)
|
|
457
|
+
.catch(() => false);
|
|
458
|
+
if (dependencyExists) {
|
|
459
|
+
try {
|
|
460
|
+
await fsp.access(durabilityMarker);
|
|
461
|
+
return nodeModules;
|
|
462
|
+
} catch {
|
|
463
|
+
await fsyncManagedReleaseTree(dependencyRoot);
|
|
464
|
+
await fsp.writeFile(durabilityMarker, "durable-v1\n", { mode: 0o600 });
|
|
465
|
+
await fsyncManagedPath(durabilityMarker);
|
|
466
|
+
await fsyncManagedPath(dependencyRoot);
|
|
467
|
+
return nodeModules;
|
|
468
|
+
}
|
|
344
469
|
}
|
|
345
|
-
const assetName =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
470
|
+
const assetName =
|
|
471
|
+
manifestArtifact?.asset || `fased-hosted-deps-linux-${arch}-${dependencyHash}.tar.gz`;
|
|
472
|
+
const archive = manifestArtifact
|
|
473
|
+
? await downloadManifestBoundAsset({
|
|
474
|
+
releaseUrl,
|
|
475
|
+
artifact: manifestArtifact,
|
|
476
|
+
destinationDir: temporaryRoot,
|
|
477
|
+
timeoutMs,
|
|
478
|
+
officialVersion,
|
|
479
|
+
})
|
|
480
|
+
: await downloadVerifiedAsset({
|
|
481
|
+
releaseUrl,
|
|
482
|
+
assetName,
|
|
483
|
+
destinationDir: temporaryRoot,
|
|
484
|
+
timeoutMs,
|
|
485
|
+
officialVersion,
|
|
486
|
+
});
|
|
352
487
|
await assertArchiveSafe(archive, "node_modules");
|
|
353
488
|
const staging = `${dependencyRoot}.staging-${process.pid}-${Date.now()}`;
|
|
354
489
|
await fsp.rm(staging, { recursive: true, force: true });
|
|
355
490
|
await extractArchive(archive, staging, timeoutMs);
|
|
356
491
|
await fsp.access(path.join(staging, "node_modules"));
|
|
492
|
+
await fsyncManagedReleaseTree(staging);
|
|
493
|
+
const stagingMarker = path.join(staging, ".fased-durable-v1");
|
|
494
|
+
await fsp.writeFile(stagingMarker, "durable-v1\n", { mode: 0o600 });
|
|
495
|
+
await fsyncManagedPath(stagingMarker);
|
|
496
|
+
await fsyncManagedPath(staging);
|
|
357
497
|
await fsp.mkdir(path.dirname(dependencyRoot), { recursive: true });
|
|
358
498
|
try {
|
|
359
499
|
await fsp.rename(staging, dependencyRoot);
|
|
@@ -363,7 +503,16 @@ async function ensureDependencyLayer({
|
|
|
363
503
|
}
|
|
364
504
|
await fsp.rm(staging, { recursive: true, force: true });
|
|
365
505
|
}
|
|
506
|
+
await fsyncManagedPath(path.dirname(dependencyRoot));
|
|
366
507
|
await fsp.access(nodeModules);
|
|
508
|
+
try {
|
|
509
|
+
await fsp.access(durabilityMarker);
|
|
510
|
+
} catch {
|
|
511
|
+
await fsyncManagedReleaseTree(dependencyRoot);
|
|
512
|
+
await fsp.writeFile(durabilityMarker, "durable-v1\n", { mode: 0o600 });
|
|
513
|
+
await fsyncManagedPath(durabilityMarker);
|
|
514
|
+
await fsyncManagedPath(dependencyRoot);
|
|
515
|
+
}
|
|
367
516
|
return nodeModules;
|
|
368
517
|
}
|
|
369
518
|
|
|
@@ -446,7 +595,293 @@ async function probeGatewayIdentity(configPath, expectedVersion, timeoutMs = 500
|
|
|
446
595
|
});
|
|
447
596
|
}
|
|
448
597
|
|
|
449
|
-
|
|
598
|
+
function hostedUpdaterError(error, ambiguous = Boolean(error?.hostUpdaterAmbiguous)) {
|
|
599
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
600
|
+
let normalized;
|
|
601
|
+
if (
|
|
602
|
+
/unsupported|schema|transactionId|request contains unsupported fields|updater is unavailable|ENOENT|ECONNREFUSED|closed before|invalid response/i.test(
|
|
603
|
+
message,
|
|
604
|
+
)
|
|
605
|
+
) {
|
|
606
|
+
normalized = new Error(PRE_V2_HOSTING_MIGRATION_MESSAGE, { cause: error });
|
|
607
|
+
} else {
|
|
608
|
+
normalized = error instanceof Error ? error : new Error(message);
|
|
609
|
+
}
|
|
610
|
+
normalized.hostUpdaterAmbiguous = ambiguous;
|
|
611
|
+
return normalized;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parseSignerReleaseIdentity(value, expectedVersion) {
|
|
615
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
616
|
+
throw new Error("signer release identity is missing");
|
|
617
|
+
}
|
|
618
|
+
if (Object.keys(value).toSorted().join(",") !== "buildInputDigest,commit,development,version") {
|
|
619
|
+
throw new Error("signer release identity contains unsupported fields");
|
|
620
|
+
}
|
|
621
|
+
if (
|
|
622
|
+
(expectedVersion !== undefined && value.version !== expectedVersion) ||
|
|
623
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value.version || "") ||
|
|
624
|
+
!/^[a-f0-9]{40}$/.test(value.commit || "") ||
|
|
625
|
+
!/^sha256:[a-f0-9]{64}$/.test(value.buildInputDigest || "") ||
|
|
626
|
+
value.development !== false
|
|
627
|
+
) {
|
|
628
|
+
throw new Error("signer release identity is development, malformed, or mismatched");
|
|
629
|
+
}
|
|
630
|
+
return Object.freeze({
|
|
631
|
+
version: value.version,
|
|
632
|
+
commit: value.commit,
|
|
633
|
+
buildInputDigest: value.buildInputDigest,
|
|
634
|
+
development: false,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function signerReleaseIdentitiesEqual(left, right) {
|
|
639
|
+
return (
|
|
640
|
+
left?.version === right?.version &&
|
|
641
|
+
left?.commit === right?.commit &&
|
|
642
|
+
left?.buildInputDigest === right?.buildInputDigest &&
|
|
643
|
+
left?.development === false &&
|
|
644
|
+
right?.development === false
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function canonicalReleaseJSON(value) {
|
|
649
|
+
if (Array.isArray(value)) {
|
|
650
|
+
return `[${value.map((entry) => canonicalReleaseJSON(entry)).join(",")}]`;
|
|
651
|
+
}
|
|
652
|
+
if (value && typeof value === "object") {
|
|
653
|
+
return `{${Object.keys(value)
|
|
654
|
+
.toSorted()
|
|
655
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalReleaseJSON(value[key])}`)
|
|
656
|
+
.join(",")}}`;
|
|
657
|
+
}
|
|
658
|
+
return JSON.stringify(value);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function requestHostedSignerTransaction(
|
|
662
|
+
operation,
|
|
663
|
+
transactionId,
|
|
664
|
+
version,
|
|
665
|
+
timeoutMs,
|
|
666
|
+
socketPath = process.env.FASED_HOST_UPDATER_SOCKET || HOST_UPDATER_SOCKET,
|
|
667
|
+
) {
|
|
668
|
+
if (
|
|
669
|
+
!new Set([
|
|
670
|
+
"prepareRelease",
|
|
671
|
+
"activateRelease",
|
|
672
|
+
"authorizeGatewayRelease",
|
|
673
|
+
"gateGatewayRelease",
|
|
674
|
+
"commitRelease",
|
|
675
|
+
"rollbackRelease",
|
|
676
|
+
]).has(operation)
|
|
677
|
+
) {
|
|
678
|
+
throw new Error(`unsupported host updater operation: ${operation}`);
|
|
679
|
+
}
|
|
680
|
+
if (!TRANSACTION_ID_PATTERN.test(transactionId)) {
|
|
681
|
+
throw new Error("host updater transaction ID must be a UUIDv4");
|
|
682
|
+
}
|
|
683
|
+
return await new Promise((resolve, reject) => {
|
|
684
|
+
const socket = net.createConnection({ path: socketPath });
|
|
685
|
+
socket.setEncoding("utf8");
|
|
686
|
+
socket.setTimeout(timeoutMs);
|
|
687
|
+
let body = "";
|
|
688
|
+
let settled = false;
|
|
689
|
+
let requestSent = false;
|
|
690
|
+
const fail = (error, ambiguous = requestSent) => {
|
|
691
|
+
if (settled) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
settled = true;
|
|
695
|
+
socket.destroy();
|
|
696
|
+
reject(hostedUpdaterError(error, ambiguous));
|
|
697
|
+
};
|
|
698
|
+
socket.once("connect", () => {
|
|
699
|
+
requestSent = true;
|
|
700
|
+
socket.write(
|
|
701
|
+
`${JSON.stringify({
|
|
702
|
+
schemaVersion: HOST_UPDATER_SCHEMA_VERSION,
|
|
703
|
+
op: operation,
|
|
704
|
+
transactionId,
|
|
705
|
+
version,
|
|
706
|
+
})}\n`,
|
|
707
|
+
);
|
|
708
|
+
});
|
|
709
|
+
socket.on("data", (chunk) => {
|
|
710
|
+
body += chunk;
|
|
711
|
+
const newline = body.indexOf("\n");
|
|
712
|
+
if (newline < 0 || settled) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const response = JSON.parse(body.slice(0, newline));
|
|
717
|
+
if (
|
|
718
|
+
!response?.ok ||
|
|
719
|
+
response.transactionId !== transactionId ||
|
|
720
|
+
response.version !== version
|
|
721
|
+
) {
|
|
722
|
+
const explicitPreV2Rejection =
|
|
723
|
+
response?.ok === false &&
|
|
724
|
+
/unsupported|schema|transactionId|request contains unsupported fields/i.test(
|
|
725
|
+
response?.error || "",
|
|
726
|
+
);
|
|
727
|
+
fail(
|
|
728
|
+
new Error(response?.error || `host updater rejected ${operation}`),
|
|
729
|
+
!explicitPreV2Rejection,
|
|
730
|
+
);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (response.release !== undefined) {
|
|
734
|
+
response.release = parseSignerReleaseIdentity(response.release, version);
|
|
735
|
+
} else if (
|
|
736
|
+
new Set(["prepareRelease", "activateRelease", "authorizeGatewayRelease"]).has(operation)
|
|
737
|
+
) {
|
|
738
|
+
fail(new Error(`host updater ${operation} response omitted signer release identity`));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
settled = true;
|
|
742
|
+
socket.destroy();
|
|
743
|
+
resolve(response);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
fail(
|
|
746
|
+
new Error(`host updater returned an invalid response: ${error.message}`, {
|
|
747
|
+
cause: error,
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
socket.once("timeout", () => fail(new Error(`host updater timed out during ${operation}`)));
|
|
753
|
+
socket.once("error", (error) =>
|
|
754
|
+
fail(
|
|
755
|
+
new Error(`root-managed signer updater is unavailable (${error.message})`, {
|
|
756
|
+
cause: error,
|
|
757
|
+
}),
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
socket.once("close", () => {
|
|
761
|
+
if (!settled) {
|
|
762
|
+
fail(new Error("host updater closed before returning a response"));
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function requestHostedSignerTransactionWithRetry(
|
|
769
|
+
operation,
|
|
770
|
+
transactionId,
|
|
771
|
+
version,
|
|
772
|
+
timeoutMs,
|
|
773
|
+
socketPath,
|
|
774
|
+
) {
|
|
775
|
+
let lastError;
|
|
776
|
+
let sawAmbiguousFailure = false;
|
|
777
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
778
|
+
try {
|
|
779
|
+
return await requestHostedSignerTransaction(
|
|
780
|
+
operation,
|
|
781
|
+
transactionId,
|
|
782
|
+
version,
|
|
783
|
+
timeoutMs,
|
|
784
|
+
socketPath,
|
|
785
|
+
);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
lastError = error;
|
|
788
|
+
sawAmbiguousFailure ||= error?.hostUpdaterAmbiguous === true;
|
|
789
|
+
if (attempt < 2) {
|
|
790
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (lastError) {
|
|
795
|
+
lastError.hostUpdaterAmbiguous = sawAmbiguousFailure;
|
|
796
|
+
}
|
|
797
|
+
throw lastError;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export async function authorizePreactivatedHostedGateway({
|
|
801
|
+
transactionId,
|
|
802
|
+
targetVersion,
|
|
803
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
804
|
+
}) {
|
|
805
|
+
return await requestHostedSignerTransactionWithRetry(
|
|
806
|
+
"authorizeGatewayRelease",
|
|
807
|
+
transactionId,
|
|
808
|
+
targetVersion,
|
|
809
|
+
timeoutMs,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function restartHostedGateway() {
|
|
814
|
+
const systemctl = fs.existsSync("/usr/bin/systemctl") ? "/usr/bin/systemctl" : "/bin/systemctl";
|
|
815
|
+
const shown = await runFile(systemctl, [
|
|
816
|
+
"show",
|
|
817
|
+
"fased-gateway.service",
|
|
818
|
+
"--property",
|
|
819
|
+
"MainPID",
|
|
820
|
+
"--value",
|
|
821
|
+
]);
|
|
822
|
+
const pid = Number.parseInt(shown.stdout.trim(), 10);
|
|
823
|
+
if (!shown.ok || !Number.isSafeInteger(pid) || pid <= 1) {
|
|
824
|
+
throw new Error(`Hosted Gateway MainPID unavailable: ${shown.stderr.trim()}`);
|
|
825
|
+
}
|
|
826
|
+
const status = await fsp.readFile(`/proc/${pid}/status`, "utf8");
|
|
827
|
+
const ownerUid = Number.parseInt(status.match(/^Uid:\s+(\d+)/m)?.[1] ?? "", 10);
|
|
828
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
829
|
+
if (!Number.isSafeInteger(ownerUid) || ownerUid !== currentUid) {
|
|
830
|
+
throw new Error("Hosted Gateway process is not owned by the Fased app account.");
|
|
831
|
+
}
|
|
832
|
+
process.kill(pid, "SIGTERM");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function quiesceHostedGateway(timeoutMs = 30_000) {
|
|
836
|
+
const systemctl = fs.existsSync("/usr/bin/systemctl") ? "/usr/bin/systemctl" : "/bin/systemctl";
|
|
837
|
+
const deadline = Date.now() + Math.min(timeoutMs, 30_000);
|
|
838
|
+
let signaledPid = null;
|
|
839
|
+
while (Date.now() < deadline) {
|
|
840
|
+
const shown = await runFile(systemctl, [
|
|
841
|
+
"show",
|
|
842
|
+
"fased-gateway.service",
|
|
843
|
+
"--property",
|
|
844
|
+
"MainPID",
|
|
845
|
+
"--value",
|
|
846
|
+
]);
|
|
847
|
+
if (!shown.ok) {
|
|
848
|
+
throw new Error(`Hosted Gateway state is unavailable: ${shown.stderr.trim()}`);
|
|
849
|
+
}
|
|
850
|
+
const pid = Number.parseInt(shown.stdout.trim(), 10);
|
|
851
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (pid !== signaledPid) {
|
|
855
|
+
let status;
|
|
856
|
+
try {
|
|
857
|
+
status = await fsp.readFile(`/proc/${pid}/status`, "utf8");
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (error?.code === "ENOENT") {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
const ownerUid = Number.parseInt(status.match(/^Uid:\s+(\d+)/m)?.[1] ?? "", 10);
|
|
865
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
866
|
+
if (!Number.isSafeInteger(ownerUid) || ownerUid !== currentUid) {
|
|
867
|
+
throw new Error("Hosted Gateway process is not owned by the Fased app account.");
|
|
868
|
+
}
|
|
869
|
+
process.kill(pid, "SIGTERM");
|
|
870
|
+
signaledPid = pid;
|
|
871
|
+
}
|
|
872
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
873
|
+
}
|
|
874
|
+
throw new Error("Hosted Gateway did not quiesce before the app/signer switch.");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function refreshGateway(
|
|
878
|
+
runtimeRoot,
|
|
879
|
+
manifest,
|
|
880
|
+
timeoutMs,
|
|
881
|
+
allowInactiveHosted = false,
|
|
882
|
+
expectedSignerRelease,
|
|
883
|
+
hostedServiceAlreadyRestarted = false,
|
|
884
|
+
) {
|
|
450
885
|
const cli = path.join(runtimeRoot, "fased.mjs");
|
|
451
886
|
const env = {
|
|
452
887
|
...process.env,
|
|
@@ -456,22 +891,31 @@ async function refreshGateway(runtimeRoot, manifest, timeoutMs) {
|
|
|
456
891
|
FASED_MANAGED_RUNTIME_ROOT: runtimeRoot,
|
|
457
892
|
FASED_RUNTIME_SOURCE: "managed-package",
|
|
458
893
|
};
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
894
|
+
if (manifest.profile === "hosting" && !hostedServiceAlreadyRestarted) {
|
|
895
|
+
try {
|
|
896
|
+
await restartHostedGateway();
|
|
897
|
+
} catch (error) {
|
|
898
|
+
if (!allowInactiveHosted || !error.message.includes("MainPID unavailable")) {
|
|
899
|
+
throw error;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
const installed = await runFile(process.execPath, [cli, "gateway", "install", "--force"], {
|
|
904
|
+
env,
|
|
905
|
+
timeoutMs,
|
|
906
|
+
});
|
|
907
|
+
if (!installed.ok) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
`Gateway service installation failed: ${(installed.stderr || installed.stdout).trim()}`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
const restarted = await runFile(process.execPath, [cli, "gateway", "restart"], {
|
|
913
|
+
env,
|
|
914
|
+
timeoutMs,
|
|
915
|
+
});
|
|
916
|
+
if (!restarted.ok) {
|
|
917
|
+
throw new Error(`Gateway restart failed: ${(restarted.stderr || restarted.stdout).trim()}`);
|
|
918
|
+
}
|
|
475
919
|
}
|
|
476
920
|
const deadline = Date.now() + Math.min(timeoutMs, 60_000);
|
|
477
921
|
let last = null;
|
|
@@ -485,6 +929,15 @@ async function refreshGateway(runtimeRoot, manifest, timeoutMs) {
|
|
|
485
929
|
if (!plugins.ok) {
|
|
486
930
|
throw new Error(`Plugin verification failed: ${(plugins.stderr || plugins.stdout).trim()}`);
|
|
487
931
|
}
|
|
932
|
+
if (manifest.profile === "hosting") {
|
|
933
|
+
await probeHostedSignerCompatibility(
|
|
934
|
+
process.env.FASED_WALLET_LOCAL_SIGNER_SOCKET || "/run/fased-signerd/app.sock",
|
|
935
|
+
Math.min(timeoutMs, 5000),
|
|
936
|
+
expectedSignerRelease,
|
|
937
|
+
manifest.runtime.activeVersion,
|
|
938
|
+
manifest.release?.signer?.capabilities,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
488
941
|
return;
|
|
489
942
|
}
|
|
490
943
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
@@ -494,6 +947,132 @@ async function refreshGateway(runtimeRoot, manifest, timeoutMs) {
|
|
|
494
947
|
);
|
|
495
948
|
}
|
|
496
949
|
|
|
950
|
+
async function probeHostedSignerCompatibility(
|
|
951
|
+
socketPath,
|
|
952
|
+
timeoutMs = 5000,
|
|
953
|
+
expectedRelease,
|
|
954
|
+
expectedVersion = expectedRelease?.version,
|
|
955
|
+
expectedCapabilities,
|
|
956
|
+
) {
|
|
957
|
+
const requiredFeatures = [
|
|
958
|
+
"failClosedPolicies",
|
|
959
|
+
"policyHashes",
|
|
960
|
+
"durableCaps",
|
|
961
|
+
"atomicIdempotency",
|
|
962
|
+
"ambiguousBroadcastReconciliation",
|
|
963
|
+
"signerOwnedKeys",
|
|
964
|
+
"typedSolanaTransactions",
|
|
965
|
+
"atomicMultiAssetCaps",
|
|
966
|
+
"signerControlledNativeFeeCaps",
|
|
967
|
+
];
|
|
968
|
+
const socketStat = await fsp.lstat(socketPath).catch((error) => {
|
|
969
|
+
throw new Error(`hosted signer socket is unavailable to the app account: ${error.message}`, {
|
|
970
|
+
cause: error,
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
const socketMode = socketStat.mode & 0o777;
|
|
974
|
+
if (socketStat.isSymbolicLink() || !socketStat.isSocket() || (socketMode & 0o007) !== 0) {
|
|
975
|
+
throw new Error("hosted signer socket must be a non-world-accessible Unix socket");
|
|
976
|
+
}
|
|
977
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
978
|
+
if (currentUid !== null && socketStat.uid !== currentUid) {
|
|
979
|
+
const groups = new Set([
|
|
980
|
+
...(typeof process.getgid === "function" ? [process.getgid()] : []),
|
|
981
|
+
...(typeof process.getgroups === "function" ? process.getgroups() : []),
|
|
982
|
+
]);
|
|
983
|
+
if ((socketMode & 0o020) === 0 || !groups.has(socketStat.gid)) {
|
|
984
|
+
throw new Error("hosted signer socket group is not accessible to the app account");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const response = await new Promise((resolve, reject) => {
|
|
988
|
+
const socket = net.createConnection({ path: socketPath });
|
|
989
|
+
socket.setEncoding("utf8");
|
|
990
|
+
socket.setTimeout(timeoutMs);
|
|
991
|
+
let body = "";
|
|
992
|
+
let settled = false;
|
|
993
|
+
const fail = (error) => {
|
|
994
|
+
if (settled) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
settled = true;
|
|
998
|
+
socket.destroy();
|
|
999
|
+
reject(error);
|
|
1000
|
+
};
|
|
1001
|
+
socket.once("connect", () => socket.write(`${JSON.stringify({ op: "health" })}\n`));
|
|
1002
|
+
socket.on("data", (chunk) => {
|
|
1003
|
+
body += chunk;
|
|
1004
|
+
if (Buffer.byteLength(body) > 1024 * 1024) {
|
|
1005
|
+
fail(new Error("hosted signer health response is too large"));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const newline = body.indexOf("\n");
|
|
1009
|
+
if (newline < 0 || settled) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
settled = true;
|
|
1013
|
+
socket.destroy();
|
|
1014
|
+
try {
|
|
1015
|
+
resolve(JSON.parse(body.slice(0, newline)));
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
reject(new Error("hosted signer health response is invalid", { cause: error }));
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
socket.once("timeout", () => fail(new Error("hosted signer health probe timed out")));
|
|
1021
|
+
socket.once("error", (error) =>
|
|
1022
|
+
fail(new Error(`hosted signer socket is unavailable to the app account: ${error.message}`)),
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
const result = response?.result;
|
|
1026
|
+
const protocol = result?.capabilities?.protocol;
|
|
1027
|
+
const features = new Set(result?.capabilities?.features || []);
|
|
1028
|
+
const missing = requiredFeatures.filter((feature) => !features.has(feature));
|
|
1029
|
+
const policies = result?.policies;
|
|
1030
|
+
const policyIds = new Set();
|
|
1031
|
+
const policiesValid =
|
|
1032
|
+
Array.isArray(policies) &&
|
|
1033
|
+
policies.every((policy) => {
|
|
1034
|
+
const walletId = String(policy?.walletId || "").trim();
|
|
1035
|
+
const valid =
|
|
1036
|
+
walletId.length > 0 &&
|
|
1037
|
+
!policyIds.has(walletId) &&
|
|
1038
|
+
Number.isSafeInteger(policy?.version) &&
|
|
1039
|
+
policy.version > 0 &&
|
|
1040
|
+
/^sha256:[a-f0-9]{64}$/.test(policy?.hash || "");
|
|
1041
|
+
policyIds.add(walletId);
|
|
1042
|
+
return valid;
|
|
1043
|
+
});
|
|
1044
|
+
let signerRelease = null;
|
|
1045
|
+
try {
|
|
1046
|
+
signerRelease = parseSignerReleaseIdentity(result?.release, expectedVersion);
|
|
1047
|
+
} catch {
|
|
1048
|
+
signerRelease = null;
|
|
1049
|
+
}
|
|
1050
|
+
const releaseMatches =
|
|
1051
|
+
signerRelease !== null &&
|
|
1052
|
+
(expectedRelease === undefined || signerReleaseIdentitiesEqual(signerRelease, expectedRelease));
|
|
1053
|
+
const capabilitiesMatch =
|
|
1054
|
+
expectedCapabilities === undefined ||
|
|
1055
|
+
canonicalReleaseJSON(result?.capabilities) === canonicalReleaseJSON(expectedCapabilities);
|
|
1056
|
+
if (
|
|
1057
|
+
response?.ok !== true ||
|
|
1058
|
+
result?.ready !== true ||
|
|
1059
|
+
result?.keystoreType !== "signer-owned-v2" ||
|
|
1060
|
+
protocol?.current !== 2 ||
|
|
1061
|
+
protocol?.min > 2 ||
|
|
1062
|
+
protocol?.max < 2 ||
|
|
1063
|
+
result?.capabilities?.nativeFeeReservationLamports !== 5_000_000 ||
|
|
1064
|
+
missing.length > 0 ||
|
|
1065
|
+
!policiesValid ||
|
|
1066
|
+
!releaseMatches ||
|
|
1067
|
+
!capabilitiesMatch
|
|
1068
|
+
) {
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
`hosted Gateway-to-signer compatibility check failed${missing.length ? `; missing ${missing.join(",")}` : ""}${result?.capabilities?.nativeFeeReservationLamports !== 5_000_000 ? "; invalid native fee reservation" : ""}${!releaseMatches ? "; signer release identity mismatch" : ""}${!capabilitiesMatch ? "; signer capability contract mismatch" : ""}`,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return response;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
497
1076
|
async function replaceCompatibilityLink(paths) {
|
|
498
1077
|
const target = paths.compatibilityPackageRoot;
|
|
499
1078
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
@@ -512,109 +1091,2172 @@ async function replaceCompatibilityLink(paths) {
|
|
|
512
1091
|
await atomicSymlink(paths.currentLink, target);
|
|
513
1092
|
}
|
|
514
1093
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
await copyExecutable(path.join(scripts, "fased-managed-launcher.sh"), paths.launcherPath);
|
|
518
|
-
await copyExecutable(path.join(scripts, "fased-managed-service.sh"), paths.serviceLauncherPath);
|
|
519
|
-
await copyExecutable(
|
|
520
|
-
path.join(scripts, "managed-runtime-layout.mjs"),
|
|
521
|
-
path.join(paths.updaterDir, "managed-runtime-layout.mjs"),
|
|
522
|
-
);
|
|
523
|
-
await copyExecutable(path.join(scripts, "fased-managed-updater.mjs"), paths.updaterPath);
|
|
1094
|
+
function hostedTransactionJournalPath(paths) {
|
|
1095
|
+
return path.join(paths.stateDir, "hosted-update-transaction.json");
|
|
524
1096
|
}
|
|
525
1097
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1098
|
+
function managedReleaseRoot(paths, value, label) {
|
|
1099
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1100
|
+
throw new Error(`${label} is missing from the hosted update journal`);
|
|
1101
|
+
}
|
|
1102
|
+
const resolved = path.resolve(value);
|
|
1103
|
+
const relative = path.relative(path.resolve(paths.releasesDir), resolved);
|
|
1104
|
+
if (
|
|
1105
|
+
!relative ||
|
|
1106
|
+
relative.startsWith("..") ||
|
|
1107
|
+
path.isAbsolute(relative) ||
|
|
1108
|
+
relative.includes(path.sep)
|
|
1109
|
+
) {
|
|
1110
|
+
throw new Error(`${label} is outside the managed releases directory`);
|
|
1111
|
+
}
|
|
1112
|
+
return resolved;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function validateHostedTransactionJournal(paths, value) {
|
|
1116
|
+
if (
|
|
1117
|
+
!value ||
|
|
1118
|
+
typeof value !== "object" ||
|
|
1119
|
+
value.schemaVersion !== HOSTED_TRANSACTION_SCHEMA_VERSION ||
|
|
1120
|
+
!HOSTED_TRANSACTION_PHASES.has(value.phase) ||
|
|
1121
|
+
!TRANSACTION_ID_PATTERN.test(value.transactionId || "")
|
|
1122
|
+
) {
|
|
1123
|
+
throw new Error("hosted application update journal is invalid");
|
|
1124
|
+
}
|
|
1125
|
+
const targetVersion = String(value.targetVersion || "").trim();
|
|
1126
|
+
const previousVersion = String(value.previousVersion || "").trim();
|
|
1127
|
+
if (
|
|
1128
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(targetVersion) ||
|
|
1129
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(previousVersion)
|
|
1130
|
+
) {
|
|
1131
|
+
throw new Error("hosted application update journal contains an invalid release version");
|
|
1132
|
+
}
|
|
1133
|
+
const targetRoot = managedReleaseRoot(paths, value.targetRoot, "targetRoot");
|
|
1134
|
+
const previousRoot = managedReleaseRoot(paths, value.previousRoot, "previousRoot");
|
|
1135
|
+
if (
|
|
1136
|
+
value.nextManifest?.profile !== "hosting" ||
|
|
1137
|
+
value.previousManifest?.profile !== "hosting" ||
|
|
1138
|
+
value.nextManifest?.runtime?.activeVersion !== targetVersion ||
|
|
1139
|
+
value.previousManifest?.runtime?.activeVersion !== previousVersion
|
|
1140
|
+
) {
|
|
1141
|
+
throw new Error("hosted application update journal manifests do not match the transaction");
|
|
1142
|
+
}
|
|
1143
|
+
const signerRelease =
|
|
1144
|
+
value.signerRelease == null
|
|
1145
|
+
? null
|
|
1146
|
+
: parseSignerReleaseIdentity(value.signerRelease, targetVersion);
|
|
1147
|
+
return {
|
|
1148
|
+
...value,
|
|
1149
|
+
transactionId: value.transactionId.toLowerCase(),
|
|
1150
|
+
targetVersion,
|
|
1151
|
+
previousVersion,
|
|
1152
|
+
targetRoot,
|
|
1153
|
+
previousRoot,
|
|
1154
|
+
signerRelease,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function fsyncManagedPath(targetPath) {
|
|
1159
|
+
const handle = await fsp.open(targetPath, "r");
|
|
529
1160
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
|
|
533
|
-
);
|
|
1161
|
+
await handle.sync();
|
|
1162
|
+
} finally {
|
|
534
1163
|
await handle.close();
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async function fsyncManagedReleaseTree(rootPath) {
|
|
1168
|
+
const directories = [];
|
|
1169
|
+
const pending = [rootPath];
|
|
1170
|
+
while (pending.length > 0) {
|
|
1171
|
+
const current = pending.pop();
|
|
1172
|
+
directories.push(current);
|
|
1173
|
+
const directory = await fsp.opendir(current);
|
|
1174
|
+
for await (const entry of directory) {
|
|
1175
|
+
const entryPath = path.join(current, entry.name);
|
|
1176
|
+
if (entry.isDirectory()) {
|
|
1177
|
+
pending.push(entryPath);
|
|
1178
|
+
} else if (entry.isFile()) {
|
|
1179
|
+
await fsyncManagedPath(entryPath);
|
|
545
1180
|
}
|
|
546
|
-
} catch {
|
|
547
|
-
active = false;
|
|
548
|
-
}
|
|
549
|
-
if (active) {
|
|
550
|
-
throw new Error(`Another Fased update is already running (${lockPath}).`, {
|
|
551
|
-
cause: error,
|
|
552
|
-
});
|
|
553
1181
|
}
|
|
554
|
-
await fsp.rm(lockPath, { force: true });
|
|
555
|
-
return await acquireUpdateLock(stateDir);
|
|
556
1182
|
}
|
|
557
|
-
|
|
1183
|
+
for (const directory of directories.toReversed()) {
|
|
1184
|
+
await fsyncManagedPath(directory);
|
|
1185
|
+
}
|
|
558
1186
|
}
|
|
559
1187
|
|
|
560
|
-
async function
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
...process.env,
|
|
568
|
-
FASED_MANAGED_RUNTIME_ROOT: currentRoot,
|
|
569
|
-
FASED_RUNTIME_SOURCE: "managed-package",
|
|
570
|
-
},
|
|
571
|
-
});
|
|
572
|
-
process.stdout.write(result.stdout);
|
|
573
|
-
process.stderr.write(result.stderr);
|
|
574
|
-
process.exitCode = result.ok ? 0 : 1;
|
|
1188
|
+
async function syncManagedActivation(paths) {
|
|
1189
|
+
await fsyncManagedPath(paths.manifestPath);
|
|
1190
|
+
await Promise.all([
|
|
1191
|
+
fsyncManagedPath(paths.stateDir),
|
|
1192
|
+
fsyncManagedPath(paths.runtimeDir),
|
|
1193
|
+
fsyncManagedPath(path.dirname(paths.compatibilityPackageRoot)),
|
|
1194
|
+
]);
|
|
575
1195
|
}
|
|
576
1196
|
|
|
577
|
-
async function
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
) {
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
const target = path.join(paths.releasesDir, entry.name);
|
|
590
|
-
if (!keep.has(path.resolve(target))) {
|
|
591
|
-
await fsp.rm(target, { recursive: true, force: true });
|
|
1197
|
+
async function readHostedTransactionJournal(paths) {
|
|
1198
|
+
try {
|
|
1199
|
+
return validateHostedTransactionJournal(
|
|
1200
|
+
paths,
|
|
1201
|
+
JSON.parse(await fsp.readFile(hostedTransactionJournalPath(paths), "utf8")),
|
|
1202
|
+
);
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
if (error?.code === "ENOENT") {
|
|
1205
|
+
return null;
|
|
592
1206
|
}
|
|
1207
|
+
throw error;
|
|
593
1208
|
}
|
|
594
1209
|
}
|
|
595
1210
|
|
|
596
|
-
async function
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1211
|
+
async function writeHostedTransactionJournal(paths, journal) {
|
|
1212
|
+
const next = validateHostedTransactionJournal(paths, {
|
|
1213
|
+
...journal,
|
|
1214
|
+
schemaVersion: HOSTED_TRANSACTION_SCHEMA_VERSION,
|
|
1215
|
+
updatedAt: new Date().toISOString(),
|
|
1216
|
+
});
|
|
1217
|
+
const journalPath = hostedTransactionJournalPath(paths);
|
|
1218
|
+
await atomicWriteJson(journalPath, next, 0o600);
|
|
1219
|
+
await fsyncManagedPath(journalPath);
|
|
1220
|
+
await fsyncManagedPath(path.dirname(journalPath));
|
|
1221
|
+
return next;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function removeHostedTransactionJournal(paths) {
|
|
1225
|
+
const journalPath = hostedTransactionJournalPath(paths);
|
|
1226
|
+
await fsp.rm(journalPath, { force: true });
|
|
1227
|
+
await fsyncManagedPath(path.dirname(journalPath));
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async function activateHostedApplication(paths, journal) {
|
|
1231
|
+
if (path.resolve(journal.previousRoot) !== path.resolve(journal.targetRoot)) {
|
|
1232
|
+
await atomicSymlink(journal.previousRoot, paths.previousLink);
|
|
605
1233
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
resolveTargetVersion(options),
|
|
612
|
-
);
|
|
1234
|
+
await atomicSymlink(journal.targetRoot, paths.currentLink);
|
|
1235
|
+
await replaceCompatibilityLink(paths);
|
|
1236
|
+
await atomicWriteJson(paths.manifestPath, journal.nextManifest, 0o600);
|
|
1237
|
+
await syncManagedActivation(paths);
|
|
1238
|
+
}
|
|
613
1239
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1240
|
+
async function restoreHostedApplication(paths, journal) {
|
|
1241
|
+
await atomicSymlink(journal.previousRoot, paths.currentLink);
|
|
1242
|
+
await replaceCompatibilityLink(paths);
|
|
1243
|
+
await atomicWriteJson(paths.manifestPath, journal.previousManifest, 0o600);
|
|
1244
|
+
await syncManagedActivation(paths);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function rollbackHostedReleaseTransaction(journal, operations, originalError = null) {
|
|
1248
|
+
const failures = [];
|
|
1249
|
+
let gatewayQuiesced = false;
|
|
1250
|
+
let applicationRestored = false;
|
|
1251
|
+
let signerRestored = false;
|
|
1252
|
+
try {
|
|
1253
|
+
journal = await operations.writePhase(journal, "rolling-back");
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
failures.push(`journal: ${error.message}`);
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
await operations.quiesceGateway(journal);
|
|
1259
|
+
gatewayQuiesced = true;
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
failures.push(`Gateway quiesce: ${error.message}`);
|
|
1262
|
+
}
|
|
1263
|
+
if (gatewayQuiesced) {
|
|
1264
|
+
try {
|
|
1265
|
+
await operations.signerRequest("gateGatewayRelease", journal);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
failures.push(`Gateway transaction gate: ${error.message}`);
|
|
1268
|
+
}
|
|
1269
|
+
if (failures.length === 0) {
|
|
1270
|
+
try {
|
|
1271
|
+
await operations.restoreApplication(journal);
|
|
1272
|
+
applicationRestored = true;
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
failures.push(`application restore: ${error.message}`);
|
|
1275
|
+
}
|
|
1276
|
+
if (applicationRestored) {
|
|
1277
|
+
try {
|
|
1278
|
+
await operations.signerRequest("rollbackRelease", journal);
|
|
1279
|
+
signerRestored = true;
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
failures.push(`signer rollback: ${error.message}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (applicationRestored && signerRestored) {
|
|
1287
|
+
try {
|
|
1288
|
+
journal = await operations.writePhase(journal, "rollback-ready");
|
|
1289
|
+
await operations.refreshPrevious(journal);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
failures.push(`previous Gateway refresh: ${error.message}`);
|
|
1292
|
+
}
|
|
1293
|
+
} else {
|
|
1294
|
+
failures.push("previous Gateway refresh skipped until both app and signer are restored");
|
|
1295
|
+
}
|
|
1296
|
+
if (failures.length > 0) {
|
|
1297
|
+
const error = new Error(
|
|
1298
|
+
`Hosted update recovery is incomplete (${failures.join("; ")}). Re-run fased update after correcting the reported failure.`,
|
|
1299
|
+
{ cause: originalError || undefined },
|
|
1300
|
+
);
|
|
1301
|
+
error.code = "HOSTED_ROLLBACK_INCOMPLETE";
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
await operations.removeJournal();
|
|
1305
|
+
if (originalError) {
|
|
1306
|
+
const error = new Error(
|
|
1307
|
+
`Update rolled back after coordinated health verification failed: ${originalError.message}`,
|
|
1308
|
+
{ cause: originalError },
|
|
1309
|
+
);
|
|
1310
|
+
error.code = "HOSTED_UPDATE_ROLLED_BACK";
|
|
1311
|
+
throw error;
|
|
1312
|
+
}
|
|
1313
|
+
return { action: "rolled-back", journal };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function coordinateHostedReleaseTransaction(journal, operations) {
|
|
1317
|
+
let durableCommitDecision = journal.phase === "gateway-verified";
|
|
1318
|
+
try {
|
|
1319
|
+
if (journal.phase === "prepared") {
|
|
1320
|
+
journal = await operations.writePhase(journal, "quiescing");
|
|
1321
|
+
await operations.quiesceGateway(journal);
|
|
1322
|
+
await operations.activateApplication(journal);
|
|
1323
|
+
journal = await operations.writePhase(journal, "app-active");
|
|
1324
|
+
}
|
|
1325
|
+
if (journal.phase === "app-active") {
|
|
1326
|
+
await operations.signerRequest("activateRelease", journal);
|
|
1327
|
+
journal = await operations.writePhase(journal, "signer-active");
|
|
1328
|
+
}
|
|
1329
|
+
if (journal.phase === "signer-preactivated") {
|
|
1330
|
+
await operations.quiesceGateway(journal);
|
|
1331
|
+
await operations.activateApplication(journal);
|
|
1332
|
+
journal = await operations.writePhase(journal, "signer-active");
|
|
1333
|
+
}
|
|
1334
|
+
if (journal.phase === "signer-active") {
|
|
1335
|
+
await operations.signerRequest("authorizeGatewayRelease", journal);
|
|
1336
|
+
await operations.verifyGateway(journal);
|
|
1337
|
+
journal = await operations.writePhase(journal, "gateway-verified");
|
|
1338
|
+
durableCommitDecision = true;
|
|
1339
|
+
}
|
|
1340
|
+
if (journal.phase !== "gateway-verified") {
|
|
1341
|
+
throw new Error(`cannot resume hosted update from phase ${journal.phase}`);
|
|
1342
|
+
}
|
|
1343
|
+
await operations.signerRequest("commitRelease", journal);
|
|
1344
|
+
await operations.finalizeApplication(journal);
|
|
1345
|
+
await operations.removeJournal();
|
|
1346
|
+
return { action: "committed", journal };
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
if (durableCommitDecision) {
|
|
1349
|
+
const pending = new Error(
|
|
1350
|
+
`The new Gateway and signer passed health verification, but final commit cleanup is pending: ${error.message}. Re-run fased update to finish the committed release; do not downgrade manually.`,
|
|
1351
|
+
{ cause: error },
|
|
1352
|
+
);
|
|
1353
|
+
pending.code = "HOSTED_COMMIT_PENDING";
|
|
1354
|
+
throw pending;
|
|
1355
|
+
}
|
|
1356
|
+
return await rollbackHostedReleaseTransaction(journal, operations, error);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function hostedTransactionOperations(paths, timeoutMs, options = {}) {
|
|
1361
|
+
const targetServiceAlreadyRestarted = options.targetServiceAlreadyRestarted === true;
|
|
1362
|
+
return {
|
|
1363
|
+
activateApplication: async (journal) => await activateHostedApplication(paths, journal),
|
|
1364
|
+
restoreApplication: async (journal) => {
|
|
1365
|
+
await restoreHostedApplication(paths, journal);
|
|
1366
|
+
await updateStableComponents(paths, journal.previousRoot, true);
|
|
1367
|
+
},
|
|
1368
|
+
quiesceGateway: async () => await quiesceHostedGateway(timeoutMs),
|
|
1369
|
+
signerRequest: async (operation, journal) => {
|
|
1370
|
+
const response = await requestHostedSignerTransactionWithRetry(
|
|
1371
|
+
operation,
|
|
1372
|
+
journal.transactionId,
|
|
1373
|
+
journal.targetVersion,
|
|
1374
|
+
timeoutMs,
|
|
1375
|
+
);
|
|
1376
|
+
if (response.release) {
|
|
1377
|
+
const manifestSignerRelease = journal.nextManifest?.release?.signer?.release;
|
|
1378
|
+
const applicationCommit = journal.nextManifest?.release?.commit;
|
|
1379
|
+
if (
|
|
1380
|
+
(manifestSignerRelease &&
|
|
1381
|
+
!signerReleaseIdentitiesEqual(manifestSignerRelease, response.release)) ||
|
|
1382
|
+
(applicationCommit && applicationCommit !== response.release.commit)
|
|
1383
|
+
) {
|
|
1384
|
+
throw new Error(
|
|
1385
|
+
"attested hosted application and native signer release identities do not match",
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
if (
|
|
1389
|
+
journal.signerRelease &&
|
|
1390
|
+
!signerReleaseIdentitiesEqual(journal.signerRelease, response.release)
|
|
1391
|
+
) {
|
|
1392
|
+
throw new Error("root updater changed signer release identity during the transaction");
|
|
1393
|
+
}
|
|
1394
|
+
journal.signerRelease ??= response.release;
|
|
1395
|
+
}
|
|
1396
|
+
return response;
|
|
1397
|
+
},
|
|
1398
|
+
verifyGateway: async (journal) =>
|
|
1399
|
+
await refreshGateway(
|
|
1400
|
+
journal.targetRoot,
|
|
1401
|
+
journal.nextManifest,
|
|
1402
|
+
timeoutMs,
|
|
1403
|
+
true,
|
|
1404
|
+
journal.signerRelease,
|
|
1405
|
+
targetServiceAlreadyRestarted,
|
|
1406
|
+
),
|
|
1407
|
+
refreshPrevious: async (journal) =>
|
|
1408
|
+
await refreshGateway(
|
|
1409
|
+
journal.previousRoot,
|
|
1410
|
+
journal.previousManifest,
|
|
1411
|
+
timeoutMs,
|
|
1412
|
+
true,
|
|
1413
|
+
journal.previousManifest?.signer?.release,
|
|
1414
|
+
),
|
|
1415
|
+
finalizeApplication: async (journal) => {
|
|
1416
|
+
if (!journal.signerRelease) {
|
|
1417
|
+
throw new Error("cannot commit a hosted runtime without signer release identity");
|
|
1418
|
+
}
|
|
1419
|
+
journal.nextManifest = {
|
|
1420
|
+
...journal.nextManifest,
|
|
1421
|
+
signer: { release: journal.signerRelease },
|
|
1422
|
+
};
|
|
1423
|
+
await atomicWriteJson(paths.manifestPath, journal.nextManifest, 0o600);
|
|
1424
|
+
await syncManagedActivation(paths);
|
|
1425
|
+
await updateStableComponents(paths, journal.targetRoot, true);
|
|
1426
|
+
await cleanupReleases(paths, [journal.targetRoot, journal.previousRoot]);
|
|
1427
|
+
},
|
|
1428
|
+
writePhase: async (journal, phase) =>
|
|
1429
|
+
await writeHostedTransactionJournal(paths, { ...journal, phase }),
|
|
1430
|
+
removeJournal: async () => await removeHostedTransactionJournal(paths),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
export async function beginPreactivatedHostedTransaction({
|
|
1435
|
+
paths,
|
|
1436
|
+
transactionId,
|
|
1437
|
+
targetVersion,
|
|
1438
|
+
previousVersion,
|
|
1439
|
+
targetRoot,
|
|
1440
|
+
previousRoot,
|
|
1441
|
+
nextManifest,
|
|
1442
|
+
previousManifest,
|
|
1443
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1444
|
+
}) {
|
|
1445
|
+
const operations = hostedTransactionOperations(paths, timeoutMs);
|
|
1446
|
+
let journal = await readHostedTransactionJournal(paths);
|
|
1447
|
+
if (journal) {
|
|
1448
|
+
if (
|
|
1449
|
+
journal.transactionId !== transactionId.toLowerCase() ||
|
|
1450
|
+
journal.targetVersion !== targetVersion ||
|
|
1451
|
+
path.resolve(journal.targetRoot) !== path.resolve(targetRoot) ||
|
|
1452
|
+
path.resolve(journal.previousRoot) !== path.resolve(previousRoot)
|
|
1453
|
+
) {
|
|
1454
|
+
throw new Error(
|
|
1455
|
+
`another hosted application transaction is unfinished (${journal.transactionId}, v${journal.targetVersion})`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
} else {
|
|
1459
|
+
journal = await writeHostedTransactionJournal(paths, {
|
|
1460
|
+
schemaVersion: HOSTED_TRANSACTION_SCHEMA_VERSION,
|
|
1461
|
+
transactionId,
|
|
1462
|
+
targetVersion,
|
|
1463
|
+
previousVersion,
|
|
1464
|
+
targetRoot,
|
|
1465
|
+
previousRoot,
|
|
1466
|
+
nextManifest,
|
|
1467
|
+
previousManifest,
|
|
1468
|
+
phase: "prepared",
|
|
1469
|
+
createdAt: new Date().toISOString(),
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (journal.phase === "prepared") {
|
|
1474
|
+
try {
|
|
1475
|
+
await operations.signerRequest("prepareRelease", journal);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
if (error?.hostUpdaterAmbiguous !== true) {
|
|
1478
|
+
await operations.removeJournal();
|
|
1479
|
+
} else {
|
|
1480
|
+
try {
|
|
1481
|
+
await operations.signerRequest("rollbackRelease", journal);
|
|
1482
|
+
await operations.removeJournal();
|
|
1483
|
+
} catch {
|
|
1484
|
+
// Preserve the shared transaction ID for deterministic repair recovery.
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
throw error;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
try {
|
|
1492
|
+
if (journal.phase === "prepared") {
|
|
1493
|
+
journal = await operations.writePhase(journal, "quiescing");
|
|
1494
|
+
}
|
|
1495
|
+
if (journal.phase === "quiescing") {
|
|
1496
|
+
await operations.quiesceGateway(journal);
|
|
1497
|
+
await operations.signerRequest("activateRelease", journal);
|
|
1498
|
+
journal = await operations.writePhase(journal, "signer-preactivated");
|
|
1499
|
+
}
|
|
1500
|
+
if (journal.phase === "signer-preactivated") {
|
|
1501
|
+
await operations.quiesceGateway(journal);
|
|
1502
|
+
await operations.activateApplication(journal);
|
|
1503
|
+
journal = await operations.writePhase(journal, "signer-active");
|
|
1504
|
+
}
|
|
1505
|
+
if (journal.phase === "app-active") {
|
|
1506
|
+
await operations.signerRequest("activateRelease", journal);
|
|
1507
|
+
journal = await operations.writePhase(journal, "signer-active");
|
|
1508
|
+
}
|
|
1509
|
+
if (journal.phase === "signer-active") {
|
|
1510
|
+
await operations.signerRequest("authorizeGatewayRelease", journal);
|
|
1511
|
+
}
|
|
1512
|
+
if (journal.phase !== "signer-active" && journal.phase !== "gateway-verified") {
|
|
1513
|
+
throw new Error(`cannot stage hosted repair from phase ${journal.phase}`);
|
|
1514
|
+
}
|
|
1515
|
+
return journal;
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
if (journal.phase === "gateway-verified") {
|
|
1518
|
+
const pending = new Error(
|
|
1519
|
+
`Hosted repair already made its durable commit decision: ${error.message}`,
|
|
1520
|
+
{ cause: error },
|
|
1521
|
+
);
|
|
1522
|
+
pending.code = "HOSTED_COMMIT_PENDING";
|
|
1523
|
+
throw pending;
|
|
1524
|
+
}
|
|
1525
|
+
return await rollbackHostedReleaseTransaction(journal, operations, error);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async function recoverHostedReleaseTransaction(paths, timeoutMs) {
|
|
1530
|
+
const journal = await readHostedTransactionJournal(paths);
|
|
1531
|
+
if (!journal) {
|
|
1532
|
+
return { recovered: false };
|
|
1533
|
+
}
|
|
1534
|
+
const operations = hostedTransactionOperations(paths, timeoutMs);
|
|
1535
|
+
if (journal.phase === "gateway-verified" || journal.phase === "signer-active") {
|
|
1536
|
+
return { recovered: true, ...(await coordinateHostedReleaseTransaction(journal, operations)) };
|
|
1537
|
+
}
|
|
1538
|
+
return {
|
|
1539
|
+
recovered: true,
|
|
1540
|
+
...(await rollbackHostedReleaseTransaction(journal, operations)),
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function runHostedTransactionControl(action, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
|
|
1545
|
+
if (!new Set(["finalize", "recover", "rollback"]).has(action)) {
|
|
1546
|
+
throw new Error("hosted-transaction requires finalize, recover, or rollback");
|
|
1547
|
+
}
|
|
1548
|
+
const paths = resolveManagedRuntimePaths();
|
|
1549
|
+
const releaseLock = await acquireUpdateLock(paths.stateDir);
|
|
1550
|
+
try {
|
|
1551
|
+
const journal = await readHostedTransactionJournal(paths);
|
|
1552
|
+
if (!journal) {
|
|
1553
|
+
if (action === "finalize") {
|
|
1554
|
+
const manifest = readManagedInstallManifest(paths.manifestPath);
|
|
1555
|
+
const currentRoot = await resolveLinkTarget(paths.currentLink);
|
|
1556
|
+
if (!manifest || manifest.profile !== "hosting" || !currentRoot) {
|
|
1557
|
+
throw new Error("hosted managed runtime is unavailable for final verification");
|
|
1558
|
+
}
|
|
1559
|
+
await refreshGateway(
|
|
1560
|
+
currentRoot,
|
|
1561
|
+
manifest,
|
|
1562
|
+
timeoutMs,
|
|
1563
|
+
true,
|
|
1564
|
+
manifest.signer?.release,
|
|
1565
|
+
options.targetServiceAlreadyRestarted === true,
|
|
1566
|
+
);
|
|
1567
|
+
return { action: "verified-current" };
|
|
1568
|
+
}
|
|
1569
|
+
return { action: "none" };
|
|
1570
|
+
}
|
|
1571
|
+
const operations = hostedTransactionOperations(paths, timeoutMs, options);
|
|
1572
|
+
if (action === "rollback") {
|
|
1573
|
+
if (journal.phase === "gateway-verified") {
|
|
1574
|
+
return await coordinateHostedReleaseTransaction(journal, operations);
|
|
1575
|
+
}
|
|
1576
|
+
return await rollbackHostedReleaseTransaction(journal, operations);
|
|
1577
|
+
}
|
|
1578
|
+
if (
|
|
1579
|
+
action === "finalize" &&
|
|
1580
|
+
!new Set(["signer-active", "gateway-verified"]).has(journal.phase)
|
|
1581
|
+
) {
|
|
1582
|
+
throw new Error(`hosted repair cannot finalize from phase ${journal.phase}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (journal.phase === "gateway-verified" || journal.phase === "signer-active") {
|
|
1585
|
+
return await coordinateHostedReleaseTransaction(journal, operations);
|
|
1586
|
+
}
|
|
1587
|
+
return await rollbackHostedReleaseTransaction(journal, operations);
|
|
1588
|
+
} finally {
|
|
1589
|
+
await releaseLock();
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function parseHostedTransactionArgs(argv) {
|
|
1594
|
+
const action = argv[1] || "recover";
|
|
1595
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
1596
|
+
let targetServiceAlreadyRestarted = false;
|
|
1597
|
+
const seen = new Set();
|
|
1598
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
1599
|
+
const argument = argv[index];
|
|
1600
|
+
if (seen.has(argument)) {
|
|
1601
|
+
throw new Error(`hosted-transaction received duplicate option ${argument}`);
|
|
1602
|
+
}
|
|
1603
|
+
seen.add(argument);
|
|
1604
|
+
if (argument === "--root-restarted") {
|
|
1605
|
+
targetServiceAlreadyRestarted = true;
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (argument === "--timeout") {
|
|
1609
|
+
const seconds = argv[index + 1] || "";
|
|
1610
|
+
if (!/^\d+$/.test(seconds)) {
|
|
1611
|
+
throw new Error("--timeout must be a positive integer (seconds)");
|
|
1612
|
+
}
|
|
1613
|
+
timeoutMs = Number.parseInt(seconds, 10) * 1000;
|
|
1614
|
+
index += 1;
|
|
1615
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
1616
|
+
throw new Error("--timeout must be a positive integer (seconds)");
|
|
1617
|
+
}
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
throw new Error("hosted-transaction accepts only --timeout <seconds> and --root-restarted");
|
|
1621
|
+
}
|
|
1622
|
+
if (targetServiceAlreadyRestarted && action !== "finalize") {
|
|
1623
|
+
throw new Error("--root-restarted is valid only with hosted-transaction finalize");
|
|
1624
|
+
}
|
|
1625
|
+
return { action, timeoutMs, targetServiceAlreadyRestarted };
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
async function updateStableComponents(paths, runtimeRoot, durable = false) {
|
|
1629
|
+
const scripts = path.join(runtimeRoot, "scripts");
|
|
1630
|
+
const stablePaths = [
|
|
1631
|
+
paths.launcherPath,
|
|
1632
|
+
paths.serviceLauncherPath,
|
|
1633
|
+
path.join(paths.updaterDir, "managed-runtime-layout.mjs"),
|
|
1634
|
+
path.join(paths.updaterDir, "hosted-release-manifest.mjs"),
|
|
1635
|
+
paths.updaterPath,
|
|
1636
|
+
];
|
|
1637
|
+
await copyExecutable(path.join(scripts, "fased-managed-launcher.sh"), stablePaths[0]);
|
|
1638
|
+
await copyExecutable(path.join(scripts, "fased-managed-service.sh"), stablePaths[1]);
|
|
1639
|
+
await copyExecutable(path.join(scripts, "managed-runtime-layout.mjs"), stablePaths[2]);
|
|
1640
|
+
await copyExecutable(path.join(scripts, "hosted-release-manifest.mjs"), stablePaths[3]);
|
|
1641
|
+
await copyExecutable(path.join(scripts, "fased-managed-updater.mjs"), stablePaths[4]);
|
|
1642
|
+
if (!durable) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
for (const stablePath of stablePaths) {
|
|
1646
|
+
await fsyncManagedPath(stablePath);
|
|
1647
|
+
}
|
|
1648
|
+
for (const directory of new Set(stablePaths.map((value) => path.dirname(value)))) {
|
|
1649
|
+
await fsyncManagedPath(directory);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function acquireUpdateLock(stateDir) {
|
|
1654
|
+
const lockPath = path.join(stateDir, "update.lock");
|
|
1655
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
1656
|
+
try {
|
|
1657
|
+
const handle = await fsp.open(lockPath, "wx", 0o600);
|
|
1658
|
+
await handle.writeFile(
|
|
1659
|
+
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
|
|
1660
|
+
);
|
|
1661
|
+
await handle.close();
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
if (error?.code !== "EEXIST") {
|
|
1664
|
+
throw error;
|
|
1665
|
+
}
|
|
1666
|
+
let active = false;
|
|
1667
|
+
try {
|
|
1668
|
+
const existing = JSON.parse(await fsp.readFile(lockPath, "utf8"));
|
|
1669
|
+
if (Number.isInteger(existing.pid)) {
|
|
1670
|
+
process.kill(existing.pid, 0);
|
|
1671
|
+
active = true;
|
|
1672
|
+
}
|
|
1673
|
+
} catch {
|
|
1674
|
+
active = false;
|
|
1675
|
+
}
|
|
1676
|
+
if (active) {
|
|
1677
|
+
throw new Error(`Another Fased update is already running (${lockPath}).`, {
|
|
1678
|
+
cause: error,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
await fsp.rm(lockPath, { force: true });
|
|
1682
|
+
return await acquireUpdateLock(stateDir);
|
|
1683
|
+
}
|
|
1684
|
+
return async () => await fsp.rm(lockPath, { force: true });
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function delegateToRuntime(args, paths) {
|
|
1688
|
+
const currentRoot = await resolveLinkTarget(paths.currentLink);
|
|
1689
|
+
if (!currentRoot) {
|
|
1690
|
+
throw new Error("Managed runtime is missing; run the official repair installer once.");
|
|
1691
|
+
}
|
|
1692
|
+
const result = await runFile(process.execPath, [path.join(currentRoot, "fased.mjs"), ...args], {
|
|
1693
|
+
env: {
|
|
1694
|
+
...process.env,
|
|
1695
|
+
FASED_MANAGED_RUNTIME_ROOT: currentRoot,
|
|
1696
|
+
FASED_RUNTIME_SOURCE: "managed-package",
|
|
1697
|
+
},
|
|
1698
|
+
});
|
|
1699
|
+
process.stdout.write(result.stdout);
|
|
1700
|
+
process.stderr.write(result.stderr);
|
|
1701
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
async function cleanupReleases(paths, keepRoots) {
|
|
1705
|
+
const keep = new Set(keepRoots.filter(Boolean).map((value) => path.resolve(value)));
|
|
1706
|
+
const entries = await fsp.readdir(paths.releasesDir, { withFileTypes: true }).catch(() => []);
|
|
1707
|
+
for (const entry of entries) {
|
|
1708
|
+
if (
|
|
1709
|
+
!entry.isDirectory() ||
|
|
1710
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:\.repair-\d+-\d+)?$/.test(
|
|
1711
|
+
entry.name,
|
|
1712
|
+
)
|
|
1713
|
+
) {
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
const target = path.join(paths.releasesDir, entry.name);
|
|
1717
|
+
if (!keep.has(path.resolve(target))) {
|
|
1718
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function resolveLocalSignerPaths(overrides = {}) {
|
|
1724
|
+
const managed = resolveManagedRuntimePaths({ stateDir: overrides.stateDir });
|
|
1725
|
+
const stateDir = managed.stateDir;
|
|
1726
|
+
const materialDir = path.resolve(
|
|
1727
|
+
overrides.materialDir ||
|
|
1728
|
+
process.env.FASED_WALLET_SIGNER_STATE_DIR ||
|
|
1729
|
+
path.join(stateDir, "wallet"),
|
|
1730
|
+
);
|
|
1731
|
+
const explicitBinary = String(
|
|
1732
|
+
overrides.binaryPath || process.env.FASED_WALLET_LOCAL_SIGNER_BIN || "",
|
|
1733
|
+
).trim();
|
|
1734
|
+
const binDir = path.resolve(
|
|
1735
|
+
overrides.binDir ||
|
|
1736
|
+
process.env.FASED_LOCAL_SIGNER_BIN_DIR ||
|
|
1737
|
+
(explicitBinary ? path.dirname(explicitBinary) : path.join(stateDir, "bin")),
|
|
1738
|
+
);
|
|
1739
|
+
const binaryPath = path.resolve(explicitBinary || path.join(binDir, "fased-signerd"));
|
|
1740
|
+
const policyTemplateDir = path.resolve(
|
|
1741
|
+
process.env.FASED_LOCAL_SIGNER_POLICY_TEMPLATE_DIR ||
|
|
1742
|
+
path.join(path.dirname(binDir), "share", "signer-policies"),
|
|
1743
|
+
);
|
|
1744
|
+
const socketPath = path.resolve(
|
|
1745
|
+
overrides.socketPath ||
|
|
1746
|
+
process.env.FASED_WALLET_LOCAL_SIGNER_SOCKET ||
|
|
1747
|
+
path.join(materialDir, "local-signer.sock"),
|
|
1748
|
+
);
|
|
1749
|
+
const controlSocketPath = path.resolve(
|
|
1750
|
+
overrides.controlSocketPath ||
|
|
1751
|
+
process.env.FASED_WALLET_LOCAL_SIGNER_CONTROL_SOCKET ||
|
|
1752
|
+
path.join(materialDir, "local-signer-control.sock"),
|
|
1753
|
+
);
|
|
1754
|
+
const stateDbPath = path.resolve(
|
|
1755
|
+
overrides.stateDbPath ||
|
|
1756
|
+
process.env.FASED_WALLET_LOCAL_SIGNER_STATE_DB ||
|
|
1757
|
+
path.join(materialDir, "signerd-v2.db"),
|
|
1758
|
+
);
|
|
1759
|
+
const masterKeyPath = path.resolve(
|
|
1760
|
+
overrides.masterKeyPath ||
|
|
1761
|
+
process.env.FASED_WALLET_LOCAL_SIGNER_MASTER_KEY ||
|
|
1762
|
+
path.join(materialDir, "signerd-v2.master.key"),
|
|
1763
|
+
);
|
|
1764
|
+
const socketBase = path.basename(socketPath).endsWith(".sock")
|
|
1765
|
+
? path.basename(socketPath).slice(0, -5)
|
|
1766
|
+
: path.basename(socketPath);
|
|
1767
|
+
const pidPath = path.join(path.dirname(socketPath), `${socketBase}.pid`);
|
|
1768
|
+
const auditPath = path.join(path.dirname(socketPath), `${socketBase}.audit.jsonl`);
|
|
1769
|
+
const updateRoot = path.join(stateDir, "signer-update");
|
|
1770
|
+
const resolved = {
|
|
1771
|
+
stateDir,
|
|
1772
|
+
materialDir,
|
|
1773
|
+
binDir,
|
|
1774
|
+
binaryPath,
|
|
1775
|
+
enrollmentPath: path.join(binDir, "fased-signer-enroll"),
|
|
1776
|
+
policyHelperPath: path.join(binDir, "fased-signer-owner-policy.mjs"),
|
|
1777
|
+
policyLauncherPath: path.join(binDir, "fased-signer-policy"),
|
|
1778
|
+
policyTemplatePaths: [
|
|
1779
|
+
"README.md",
|
|
1780
|
+
"agent.json.template",
|
|
1781
|
+
"mining.json.template",
|
|
1782
|
+
"vault.json.template",
|
|
1783
|
+
"network.json.template",
|
|
1784
|
+
].map((name) => path.join(policyTemplateDir, name)),
|
|
1785
|
+
releaseManifestPath: path.join(binDir, "fased-signerd-release.json"),
|
|
1786
|
+
socketPath,
|
|
1787
|
+
controlSocketPath,
|
|
1788
|
+
stateDbPath,
|
|
1789
|
+
masterKeyPath,
|
|
1790
|
+
pidPath,
|
|
1791
|
+
legacyPidPath: `${socketPath}.pid`,
|
|
1792
|
+
auditPath,
|
|
1793
|
+
logPath: path.join(materialDir, "local-signer.log"),
|
|
1794
|
+
signerEnvPath: path.join(materialDir, "signer.env"),
|
|
1795
|
+
updateRoot,
|
|
1796
|
+
journalPath: path.join(updateRoot, "transaction.json"),
|
|
1797
|
+
transactionsDir: path.join(updateRoot, "transactions"),
|
|
1798
|
+
};
|
|
1799
|
+
for (const [label, candidate] of [
|
|
1800
|
+
["signer socket", socketPath],
|
|
1801
|
+
["signer control socket", controlSocketPath],
|
|
1802
|
+
["signer state database", stateDbPath],
|
|
1803
|
+
["signer master key", masterKeyPath],
|
|
1804
|
+
["signer PID file", pidPath],
|
|
1805
|
+
["signer audit log", auditPath],
|
|
1806
|
+
]) {
|
|
1807
|
+
const relative = path.relative(materialDir, candidate);
|
|
1808
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1809
|
+
throw new Error(
|
|
1810
|
+
`${label} must be inside the Local signer state directory for transactional updates`,
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (materialDir === path.parse(materialDir).root || materialDir === stateDir) {
|
|
1815
|
+
throw new Error("Local signer state directory is too broad for transactional snapshot/restore");
|
|
1816
|
+
}
|
|
1817
|
+
return resolved;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function parseSignerVersionOutput(raw, expectedVersion) {
|
|
1821
|
+
const match =
|
|
1822
|
+
/^fased-signerd ([^\s]+) commit=([^\s]+) buildInputDigest=([^\s]+) development=(true|false)\s*$/.exec(
|
|
1823
|
+
String(raw || ""),
|
|
1824
|
+
);
|
|
1825
|
+
if (!match) {
|
|
1826
|
+
throw new Error("fased-signerd --version returned a malformed release identity");
|
|
1827
|
+
}
|
|
1828
|
+
const identity = {
|
|
1829
|
+
version: match[1],
|
|
1830
|
+
commit: match[2],
|
|
1831
|
+
buildInputDigest: match[3],
|
|
1832
|
+
development: match[4] === "true",
|
|
1833
|
+
};
|
|
1834
|
+
if (expectedVersion !== undefined && identity.version !== expectedVersion) {
|
|
1835
|
+
throw new Error(
|
|
1836
|
+
`signer binary version ${identity.version} does not match target ${expectedVersion}`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
if (
|
|
1840
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z.-]+)?$/.test(
|
|
1841
|
+
identity.version,
|
|
1842
|
+
) ||
|
|
1843
|
+
!/^[a-f0-9]{40}$/.test(identity.commit) ||
|
|
1844
|
+
!/^sha256:[a-f0-9]{64}$/.test(identity.buildInputDigest) ||
|
|
1845
|
+
identity.development
|
|
1846
|
+
) {
|
|
1847
|
+
throw new Error("fased-signerd release identity is not an exact production identity");
|
|
1848
|
+
}
|
|
1849
|
+
return Object.freeze(identity);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function parseSignerReleaseManifest(value, expectedVersion) {
|
|
1853
|
+
if (
|
|
1854
|
+
!value ||
|
|
1855
|
+
typeof value !== "object" ||
|
|
1856
|
+
Array.isArray(value) ||
|
|
1857
|
+
Object.keys(value).toSorted().join(",") !==
|
|
1858
|
+
"buildInputDigest,commit,development,schemaVersion,version" ||
|
|
1859
|
+
value.schemaVersion !== 1
|
|
1860
|
+
) {
|
|
1861
|
+
throw new Error("fased-signerd-release.json is malformed or contains unsupported fields");
|
|
1862
|
+
}
|
|
1863
|
+
return parseSignerVersionOutput(
|
|
1864
|
+
`fased-signerd ${value.version} commit=${value.commit} buildInputDigest=${value.buildInputDigest} development=${String(value.development)}`,
|
|
1865
|
+
expectedVersion,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function signerIdentitiesEqual(left, right) {
|
|
1870
|
+
return (
|
|
1871
|
+
left?.version === right?.version &&
|
|
1872
|
+
left?.commit === right?.commit &&
|
|
1873
|
+
left?.buildInputDigest === right?.buildInputDigest &&
|
|
1874
|
+
left?.development === false &&
|
|
1875
|
+
right?.development === false
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
async function readSignerBinaryIdentity(binaryPath, expectedVersion) {
|
|
1880
|
+
const result = await runFile(binaryPath, ["--version"], { timeoutMs: 10_000 });
|
|
1881
|
+
if (!result.ok) {
|
|
1882
|
+
throw new Error(`could not execute candidate fased-signerd: ${result.stderr.trim()}`);
|
|
1883
|
+
}
|
|
1884
|
+
return parseSignerVersionOutput(result.stdout, expectedVersion);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function copyDownloadSource(source, destination, timeoutMs) {
|
|
1888
|
+
const parsed = new URL(source);
|
|
1889
|
+
if (parsed.protocol === "file:") {
|
|
1890
|
+
const sourcePath = fileURLToPath(parsed);
|
|
1891
|
+
const sourceStat = await fsp.lstat(sourcePath);
|
|
1892
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
1893
|
+
throw new Error(`Local signer release asset is not a regular file: ${sourcePath}`);
|
|
1894
|
+
}
|
|
1895
|
+
await fsp.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
1896
|
+
await fsp.copyFile(sourcePath, destination);
|
|
1897
|
+
await fsp.chmod(destination, 0o600);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
await downloadToFile(source, destination, timeoutMs);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function checksumEntry(checksums, assetName) {
|
|
1904
|
+
for (const line of String(checksums || "").split(/\r?\n/)) {
|
|
1905
|
+
const match = /^([a-fA-F0-9]{64})\s+\*?(.+)$/.exec(line.trim());
|
|
1906
|
+
if (match && match[2] === assetName) {
|
|
1907
|
+
return match[1].toLowerCase();
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
throw new Error(`fased-signerd-checksums.txt has no exact entry for ${assetName}`);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function downloadVerifiedLocalSignerRelease({ targetVersion, timeoutMs, transactionDir }) {
|
|
1914
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(targetVersion)) {
|
|
1915
|
+
throw new Error("Local signer update requires an exact canonical release version");
|
|
1916
|
+
}
|
|
1917
|
+
const { assetName } = resolveLocalSignerAsset(process.platform, process.arch);
|
|
1918
|
+
const base = String(process.env.FASED_LOCAL_SIGNER_BASE_URL || DEFAULT_RELEASE_BASE_URL).replace(
|
|
1919
|
+
/\/$/,
|
|
1920
|
+
"",
|
|
1921
|
+
);
|
|
1922
|
+
const official = base === DEFAULT_RELEASE_BASE_URL;
|
|
1923
|
+
if (!official && process.env.FASED_LOCAL_SIGNER_ALLOW_UNATTESTED !== "1") {
|
|
1924
|
+
throw new Error(
|
|
1925
|
+
"A custom signer release source is allowed only for an explicit trusted-source test with FASED_LOCAL_SIGNER_ALLOW_UNATTESTED=1.",
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
const flat = !official && process.env.FASED_LOCAL_SIGNER_FLAT_RELEASE === "1";
|
|
1929
|
+
const releaseUrl = flat ? base : `${base}/v${targetVersion}`;
|
|
1930
|
+
const manifestName = "fased-signerd-release.json";
|
|
1931
|
+
const checksumName = "fased-signerd-checksums.txt";
|
|
1932
|
+
const candidatePath = path.join(transactionDir, assetName);
|
|
1933
|
+
const manifestPath = path.join(transactionDir, manifestName);
|
|
1934
|
+
const checksumsPath = path.join(transactionDir, checksumName);
|
|
1935
|
+
await Promise.all([
|
|
1936
|
+
copyDownloadSource(`${releaseUrl}/${assetName}`, candidatePath, timeoutMs),
|
|
1937
|
+
copyDownloadSource(`${releaseUrl}/${manifestName}`, manifestPath, timeoutMs),
|
|
1938
|
+
copyDownloadSource(`${releaseUrl}/${checksumName}`, checksumsPath, timeoutMs),
|
|
1939
|
+
]);
|
|
1940
|
+
const checksums = await fsp.readFile(checksumsPath, "utf8");
|
|
1941
|
+
for (const [name, filePath] of [
|
|
1942
|
+
[assetName, candidatePath],
|
|
1943
|
+
[manifestName, manifestPath],
|
|
1944
|
+
]) {
|
|
1945
|
+
const expected = checksumEntry(checksums, name);
|
|
1946
|
+
const actual = await sha256File(filePath);
|
|
1947
|
+
if (expected !== actual) {
|
|
1948
|
+
throw new Error(`Checksum mismatch for ${name}`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
if (official) {
|
|
1952
|
+
await verifyOfficialAsset(candidatePath, targetVersion, timeoutMs);
|
|
1953
|
+
await verifyOfficialAsset(manifestPath, targetVersion, timeoutMs);
|
|
1954
|
+
}
|
|
1955
|
+
const manifestIdentity = parseSignerReleaseManifest(
|
|
1956
|
+
JSON.parse(await fsp.readFile(manifestPath, "utf8")),
|
|
1957
|
+
targetVersion,
|
|
1958
|
+
);
|
|
1959
|
+
await fsp.chmod(candidatePath, 0o700);
|
|
1960
|
+
const binaryIdentity = await readSignerBinaryIdentity(candidatePath, targetVersion);
|
|
1961
|
+
if (!signerIdentitiesEqual(binaryIdentity, manifestIdentity)) {
|
|
1962
|
+
throw new Error("candidate signer binary and release manifest identities do not match");
|
|
1963
|
+
}
|
|
1964
|
+
return { candidatePath, manifestPath, identity: binaryIdentity, official, assetName };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function resolveLocalSignerAsset(platformRaw, archRaw) {
|
|
1968
|
+
const platform = platformRaw === "darwin" ? "darwin" : platformRaw;
|
|
1969
|
+
if (platform !== "linux" && platform !== "darwin") {
|
|
1970
|
+
throw new Error(
|
|
1971
|
+
platformRaw === "win32"
|
|
1972
|
+
? "Native Windows is unsupported. Install and run Fased inside WSL2."
|
|
1973
|
+
: `fased-signerd has no supported asset for ${platformRaw}`,
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
const arch = archRaw === "x64" ? "amd64" : archRaw;
|
|
1977
|
+
if (arch !== "amd64" && arch !== "arm64") {
|
|
1978
|
+
throw new Error(`fased-signerd has no supported asset for ${archRaw}`);
|
|
1979
|
+
}
|
|
1980
|
+
const assetName = `fased-signerd-${platform}-${arch}`;
|
|
1981
|
+
return { platform, arch, assetName };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function isPathInside(parent, candidate) {
|
|
1985
|
+
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
1986
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
async function fsyncDirectoryTree(root) {
|
|
1990
|
+
const directories = [];
|
|
1991
|
+
const pending = [root];
|
|
1992
|
+
while (pending.length > 0) {
|
|
1993
|
+
const current = pending.pop();
|
|
1994
|
+
directories.push(current);
|
|
1995
|
+
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
1996
|
+
for (const entry of entries) {
|
|
1997
|
+
const entryPath = path.join(current, entry.name);
|
|
1998
|
+
if (entry.isDirectory()) {
|
|
1999
|
+
pending.push(entryPath);
|
|
2000
|
+
} else if (entry.isFile()) {
|
|
2001
|
+
await fsyncManagedPath(entryPath);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
for (const directory of directories.toReversed()) {
|
|
2006
|
+
await fsyncManagedPath(directory);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function createOwnerOnlySnapshot(sourceRoot, destinationRoot, excludedPaths = []) {
|
|
2011
|
+
const excluded = new Set(excludedPaths.map((value) => path.resolve(value)));
|
|
2012
|
+
const sourceExists = await fsp
|
|
2013
|
+
.lstat(sourceRoot)
|
|
2014
|
+
.then((stat) => stat.isDirectory() && !stat.isSymbolicLink())
|
|
2015
|
+
.catch(() => false);
|
|
2016
|
+
await fsp.rm(destinationRoot, { recursive: true, force: true });
|
|
2017
|
+
await fsp.mkdir(destinationRoot, { recursive: true, mode: 0o700 });
|
|
2018
|
+
const files = [];
|
|
2019
|
+
if (sourceExists) {
|
|
2020
|
+
const pending = [{ source: sourceRoot, relative: "" }];
|
|
2021
|
+
while (pending.length > 0) {
|
|
2022
|
+
const current = pending.pop();
|
|
2023
|
+
const entries = await fsp.readdir(current.source, { withFileTypes: true });
|
|
2024
|
+
for (const entry of entries) {
|
|
2025
|
+
const sourcePath = path.join(current.source, entry.name);
|
|
2026
|
+
if (excluded.has(path.resolve(sourcePath))) {
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
const relative = path.join(current.relative, entry.name);
|
|
2030
|
+
const destinationPath = path.join(destinationRoot, relative);
|
|
2031
|
+
const stat = await fsp.lstat(sourcePath);
|
|
2032
|
+
if (stat.isSymbolicLink()) {
|
|
2033
|
+
throw new Error(`Local signer state contains an unsafe symlink: ${sourcePath}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (stat.isDirectory()) {
|
|
2036
|
+
await fsp.mkdir(destinationPath, { recursive: true, mode: 0o700 });
|
|
2037
|
+
pending.push({ source: sourcePath, relative });
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
if (!stat.isFile()) {
|
|
2041
|
+
throw new Error(`Local signer state contains an unsupported file: ${sourcePath}`);
|
|
2042
|
+
}
|
|
2043
|
+
await fsp.mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
|
|
2044
|
+
await fsp.copyFile(sourcePath, destinationPath);
|
|
2045
|
+
await fsp.chmod(destinationPath, 0o600);
|
|
2046
|
+
files.push({
|
|
2047
|
+
path: relative.split(path.sep).join("/"),
|
|
2048
|
+
size: stat.size,
|
|
2049
|
+
sha256: await sha256File(destinationPath),
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
2055
|
+
const manifest = { schemaVersion: 1, sourceExisted: sourceExists, files };
|
|
2056
|
+
const manifestPath = path.join(destinationRoot, ".snapshot.json");
|
|
2057
|
+
await atomicWriteJson(manifestPath, manifest, 0o600);
|
|
2058
|
+
await fsyncDirectoryTree(destinationRoot);
|
|
2059
|
+
return manifest;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
async function verifyOwnerOnlySnapshot(snapshotRoot) {
|
|
2063
|
+
const manifest = JSON.parse(
|
|
2064
|
+
await fsp.readFile(path.join(snapshotRoot, ".snapshot.json"), "utf8"),
|
|
2065
|
+
);
|
|
2066
|
+
if (
|
|
2067
|
+
manifest?.schemaVersion !== 1 ||
|
|
2068
|
+
typeof manifest.sourceExisted !== "boolean" ||
|
|
2069
|
+
!Array.isArray(manifest.files)
|
|
2070
|
+
) {
|
|
2071
|
+
throw new Error("Local signer state snapshot manifest is invalid");
|
|
2072
|
+
}
|
|
2073
|
+
const seen = new Set();
|
|
2074
|
+
for (const entry of manifest.files) {
|
|
2075
|
+
if (
|
|
2076
|
+
!entry ||
|
|
2077
|
+
typeof entry.path !== "string" ||
|
|
2078
|
+
entry.path.includes("\\") ||
|
|
2079
|
+
path.posix.isAbsolute(entry.path) ||
|
|
2080
|
+
entry.path.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
2081
|
+
seen.has(entry.path) ||
|
|
2082
|
+
!Number.isSafeInteger(entry.size) ||
|
|
2083
|
+
entry.size < 0 ||
|
|
2084
|
+
!/^[a-f0-9]{64}$/.test(entry.sha256 || "")
|
|
2085
|
+
) {
|
|
2086
|
+
throw new Error("Local signer state snapshot contains an invalid entry");
|
|
2087
|
+
}
|
|
2088
|
+
seen.add(entry.path);
|
|
2089
|
+
const filePath = path.join(snapshotRoot, ...entry.path.split("/"));
|
|
2090
|
+
const stat = await fsp.lstat(filePath);
|
|
2091
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== entry.size) {
|
|
2092
|
+
throw new Error(`Local signer state snapshot file is invalid: ${entry.path}`);
|
|
2093
|
+
}
|
|
2094
|
+
if ((stat.mode & 0o077) !== 0 || (await sha256File(filePath)) !== entry.sha256) {
|
|
2095
|
+
throw new Error(`Local signer state snapshot verification failed: ${entry.path}`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return manifest;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
async function restoreOwnerOnlySnapshot(snapshotRoot, destinationRoot) {
|
|
2102
|
+
const manifest = await verifyOwnerOnlySnapshot(snapshotRoot);
|
|
2103
|
+
await fsp.rm(destinationRoot, { recursive: true, force: true });
|
|
2104
|
+
if (!manifest.sourceExisted) {
|
|
2105
|
+
await fsp.mkdir(path.dirname(destinationRoot), { recursive: true, mode: 0o700 });
|
|
2106
|
+
await fsyncManagedPath(path.dirname(destinationRoot));
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
await fsp.mkdir(destinationRoot, { recursive: true, mode: 0o700 });
|
|
2110
|
+
for (const entry of manifest.files) {
|
|
2111
|
+
const sourcePath = path.join(snapshotRoot, ...entry.path.split("/"));
|
|
2112
|
+
const destinationPath = path.join(destinationRoot, ...entry.path.split("/"));
|
|
2113
|
+
await fsp.mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
|
|
2114
|
+
await fsp.copyFile(sourcePath, destinationPath);
|
|
2115
|
+
await fsp.chmod(destinationPath, 0o600);
|
|
2116
|
+
}
|
|
2117
|
+
await fsyncDirectoryTree(destinationRoot);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
async function readProcessCommand(pid) {
|
|
2121
|
+
if (process.platform === "linux") {
|
|
2122
|
+
const raw = await fsp.readFile(`/proc/${pid}/cmdline`).catch(() => null);
|
|
2123
|
+
if (raw) {
|
|
2124
|
+
return raw.toString("utf8").replaceAll("\0", " ").trim();
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
const result = await runFile("ps", ["-p", String(pid), "-o", "command="], {
|
|
2128
|
+
timeoutMs: 5_000,
|
|
2129
|
+
});
|
|
2130
|
+
return result.ok ? result.stdout.trim() : "";
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function processIsAlive(pid) {
|
|
2134
|
+
try {
|
|
2135
|
+
process.kill(pid, 0);
|
|
2136
|
+
return true;
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
return error?.code === "EPERM";
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
async function requireExactSignerPid(pid, binaryPath) {
|
|
2143
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || !processIsAlive(pid)) {
|
|
2144
|
+
return false;
|
|
2145
|
+
}
|
|
2146
|
+
const command = await readProcessCommand(pid);
|
|
2147
|
+
const expected = path.resolve(binaryPath);
|
|
2148
|
+
if (!command.includes("fased-signerd") || !command.includes(expected)) {
|
|
2149
|
+
throw new Error(
|
|
2150
|
+
`PID ${pid} is not the exact Local signer executable ${expected}; refusing to signal it`,
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
async function resolveRunningSignerPid(paths) {
|
|
2157
|
+
const pids = new Set();
|
|
2158
|
+
for (const pidPath of new Set([paths.pidPath, paths.legacyPidPath])) {
|
|
2159
|
+
try {
|
|
2160
|
+
const pid = Number.parseInt((await fsp.readFile(pidPath, "utf8")).trim(), 10);
|
|
2161
|
+
if (await requireExactSignerPid(pid, paths.binaryPath)) {
|
|
2162
|
+
pids.add(pid);
|
|
2163
|
+
} else {
|
|
2164
|
+
await fsp.rm(pidPath, { force: true });
|
|
2165
|
+
}
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
if (error?.code !== "ENOENT") {
|
|
2168
|
+
throw error;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
if (pids.size > 1) {
|
|
2173
|
+
throw new Error("Multiple Local signer PIDs were recorded; refusing an ambiguous update");
|
|
2174
|
+
}
|
|
2175
|
+
if (pids.size === 0) {
|
|
2176
|
+
const socketExists = await fsp
|
|
2177
|
+
.lstat(paths.socketPath)
|
|
2178
|
+
.then((stat) => stat.isSocket())
|
|
2179
|
+
.catch(() => false);
|
|
2180
|
+
if (socketExists) {
|
|
2181
|
+
throw new Error("Local signer socket exists without a verifiable exact PID; repair manually");
|
|
2182
|
+
}
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
return [...pids][0];
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
async function stopExactSigner(paths, explicitPid) {
|
|
2189
|
+
const pid = explicitPid ?? (await resolveRunningSignerPid(paths));
|
|
2190
|
+
if (pid !== null) {
|
|
2191
|
+
await requireExactSignerPid(pid, paths.binaryPath);
|
|
2192
|
+
process.kill(pid, "SIGTERM");
|
|
2193
|
+
const deadline = Date.now() + 10_000;
|
|
2194
|
+
while (Date.now() < deadline && processIsAlive(pid)) {
|
|
2195
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2196
|
+
}
|
|
2197
|
+
if (processIsAlive(pid)) {
|
|
2198
|
+
await requireExactSignerPid(pid, paths.binaryPath);
|
|
2199
|
+
process.kill(pid, "SIGKILL");
|
|
2200
|
+
}
|
|
2201
|
+
const killedDeadline = Date.now() + 3_000;
|
|
2202
|
+
while (Date.now() < killedDeadline && processIsAlive(pid)) {
|
|
2203
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2204
|
+
}
|
|
2205
|
+
if (processIsAlive(pid)) {
|
|
2206
|
+
throw new Error(`Local signer PID ${pid} did not stop; refusing to copy live state`);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
await Promise.all([
|
|
2210
|
+
fsp.rm(paths.socketPath, { force: true }),
|
|
2211
|
+
fsp.rm(paths.controlSocketPath, { force: true }),
|
|
2212
|
+
fsp.rm(paths.pidPath, { force: true }),
|
|
2213
|
+
fsp.rm(paths.legacyPidPath, { force: true }),
|
|
2214
|
+
]);
|
|
2215
|
+
const stillRunning = await resolveRunningSignerPid(paths);
|
|
2216
|
+
if (stillRunning !== null) {
|
|
2217
|
+
throw new Error("Local signer remained active after stop; refusing an online state snapshot");
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
async function loadSignerEnvironment(paths) {
|
|
2222
|
+
const env = { ...process.env };
|
|
2223
|
+
const raw = await fsp.readFile(paths.signerEnvPath, "utf8").catch(() => "");
|
|
2224
|
+
const locationKeys = new Set([
|
|
2225
|
+
"FASED_WALLET_LOCAL_SIGNER_SOCKET",
|
|
2226
|
+
"FASED_WALLET_LOCAL_SIGNER_CONTROL_SOCKET",
|
|
2227
|
+
"FASED_WALLET_LOCAL_SIGNER_STATE_DB",
|
|
2228
|
+
"FASED_WALLET_LOCAL_SIGNER_MASTER_KEY",
|
|
2229
|
+
"FASED_WALLET_SIGNER_STATE_DIR",
|
|
2230
|
+
"FASED_WALLET_LOCAL_SIGNER_BIN",
|
|
2231
|
+
]);
|
|
2232
|
+
const commandPattern =
|
|
2233
|
+
/^"(?:[^"\\]|\\[\\"$`])*" --socket "\$FASED_WALLET_LOCAL_SIGNER_SOCKET" --control-socket "\$FASED_WALLET_LOCAL_SIGNER_CONTROL_SOCKET" --state-db "\$FASED_WALLET_LOCAL_SIGNER_STATE_DB" --master-key "\$FASED_WALLET_LOCAL_SIGNER_MASTER_KEY"$/u;
|
|
2234
|
+
const decodeValue = (rawValue) => {
|
|
2235
|
+
if (rawValue.length < 2 || rawValue[0] !== '"' || rawValue.at(-1) !== '"') {
|
|
2236
|
+
throw new Error("Local signer environment values must use generated double-quote syntax");
|
|
2237
|
+
}
|
|
2238
|
+
let value = "";
|
|
2239
|
+
for (let index = 1; index < rawValue.length - 1; index += 1) {
|
|
2240
|
+
const current = rawValue[index];
|
|
2241
|
+
if (current !== "\\") {
|
|
2242
|
+
value += current;
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
const escaped = rawValue[index + 1];
|
|
2246
|
+
if (!escaped || !new Set(["\\", '"', "$", "`"]).has(escaped)) {
|
|
2247
|
+
throw new Error("Local signer environment contains an unsupported escape");
|
|
2248
|
+
}
|
|
2249
|
+
value += escaped;
|
|
2250
|
+
index += 1;
|
|
2251
|
+
}
|
|
2252
|
+
return value;
|
|
2253
|
+
};
|
|
2254
|
+
for (const rawLine of raw.split(/\r?\n/u)) {
|
|
2255
|
+
const line = rawLine.trim();
|
|
2256
|
+
if (!line || line.startsWith("#")) {
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
if (!line.startsWith("export ")) {
|
|
2260
|
+
if (commandPattern.test(line)) {
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
throw new Error("Local signer environment file contains an invalid line");
|
|
2264
|
+
}
|
|
2265
|
+
const assignment = line.slice("export ".length);
|
|
2266
|
+
const separator = assignment.indexOf("=");
|
|
2267
|
+
if (separator <= 0) {
|
|
2268
|
+
throw new Error("Local signer environment file contains an invalid line");
|
|
2269
|
+
}
|
|
2270
|
+
const key = assignment.slice(0, separator);
|
|
2271
|
+
if (!/^[A-Z][A-Z0-9_]*$/u.test(key)) {
|
|
2272
|
+
throw new Error("Local signer environment file contains an invalid key");
|
|
2273
|
+
}
|
|
2274
|
+
const value = decodeValue(assignment.slice(separator + 1));
|
|
2275
|
+
if (
|
|
2276
|
+
key === "FASED_WALLET_CHAINS" ||
|
|
2277
|
+
key === "FASED_WALLET_WEBAUTHN_RP_ID" ||
|
|
2278
|
+
key === "FASED_WALLET_WEBAUTHN_ORIGINS" ||
|
|
2279
|
+
key.startsWith("FASED_WALLET_LOCAL_SIGNER_RATE_") ||
|
|
2280
|
+
key === "FASED_WALLET_LOCAL_SIGNER_AUDIT_MAX_BYTES" ||
|
|
2281
|
+
key === "FASED_WALLET_SOLANA_CONFIRM_TIMEOUT_MS" ||
|
|
2282
|
+
key === "FASED_WALLET_SOLANA_WRITE_RPC_TIMEOUT_MS"
|
|
2283
|
+
) {
|
|
2284
|
+
env[key] = value;
|
|
2285
|
+
} else if (locationKeys.has(key)) {
|
|
2286
|
+
if (!path.isAbsolute(value)) {
|
|
2287
|
+
throw new Error(`Local signer environment path ${key} must be absolute`);
|
|
2288
|
+
}
|
|
2289
|
+
} else {
|
|
2290
|
+
throw new Error(`Local signer environment file contains unsupported key ${key}`);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
delete env.FASED_WALLET_SOLANA_KEYSTORE_PATH;
|
|
2294
|
+
delete env.FASED_WALLET_EVM_KEYSTORE_PATH;
|
|
2295
|
+
delete env.FASED_WALLET_PASSPHRASE;
|
|
2296
|
+
delete env.FASED_WALLET_PASSPHRASE_FILE;
|
|
2297
|
+
return env;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
async function probeLocalSignerHealth(
|
|
2301
|
+
socketPath,
|
|
2302
|
+
expectedIdentity,
|
|
2303
|
+
timeoutMs = 8_000,
|
|
2304
|
+
expectedReadOnly,
|
|
2305
|
+
) {
|
|
2306
|
+
const deadline = Date.now() + timeoutMs;
|
|
2307
|
+
let lastError = null;
|
|
2308
|
+
while (Date.now() < deadline) {
|
|
2309
|
+
try {
|
|
2310
|
+
const response = await new Promise((resolve, reject) => {
|
|
2311
|
+
const socket = net.createConnection({ path: socketPath });
|
|
2312
|
+
socket.setEncoding("utf8");
|
|
2313
|
+
socket.setTimeout(Math.min(2_000, timeoutMs));
|
|
2314
|
+
let body = "";
|
|
2315
|
+
let settled = false;
|
|
2316
|
+
const fail = (error) => {
|
|
2317
|
+
if (settled) {
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
settled = true;
|
|
2321
|
+
socket.destroy();
|
|
2322
|
+
reject(error);
|
|
2323
|
+
};
|
|
2324
|
+
socket.once("connect", () => socket.write(`${JSON.stringify({ op: "health" })}\n`));
|
|
2325
|
+
socket.on("data", (chunk) => {
|
|
2326
|
+
body += chunk;
|
|
2327
|
+
if (Buffer.byteLength(body) > 1024 * 1024) {
|
|
2328
|
+
fail(new Error("Local signer health response is too large"));
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
const newline = body.indexOf("\n");
|
|
2332
|
+
if (newline < 0 || settled) {
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
settled = true;
|
|
2336
|
+
socket.destroy();
|
|
2337
|
+
try {
|
|
2338
|
+
resolve(JSON.parse(body.slice(0, newline)));
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
reject(error);
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
socket.once("timeout", () => fail(new Error("Local signer health timed out")));
|
|
2344
|
+
socket.once("error", fail);
|
|
2345
|
+
});
|
|
2346
|
+
const result = response?.result;
|
|
2347
|
+
const release = parseSignerReleaseManifest(
|
|
2348
|
+
{ schemaVersion: 1, ...result?.release },
|
|
2349
|
+
expectedIdentity.version,
|
|
2350
|
+
);
|
|
2351
|
+
const features = new Set(result?.capabilities?.features || []);
|
|
2352
|
+
const missing = LOCAL_SIGNER_REQUIRED_FEATURES.filter((feature) => !features.has(feature));
|
|
2353
|
+
const policies = result?.policies;
|
|
2354
|
+
const policiesValid =
|
|
2355
|
+
Array.isArray(policies) &&
|
|
2356
|
+
policies.every(
|
|
2357
|
+
(policy) =>
|
|
2358
|
+
typeof policy?.walletId === "string" &&
|
|
2359
|
+
policy.walletId.length > 0 &&
|
|
2360
|
+
Number.isSafeInteger(policy.version) &&
|
|
2361
|
+
policy.version > 0 &&
|
|
2362
|
+
/^sha256:[a-f0-9]{64}$/.test(policy.hash || ""),
|
|
2363
|
+
);
|
|
2364
|
+
const networks = result?.network?.wallets;
|
|
2365
|
+
const networkValid =
|
|
2366
|
+
typeof result?.network?.ready === "boolean" &&
|
|
2367
|
+
Array.isArray(networks) &&
|
|
2368
|
+
networks.every(
|
|
2369
|
+
(network) =>
|
|
2370
|
+
typeof network?.walletId === "string" &&
|
|
2371
|
+
network.walletId.length > 0 &&
|
|
2372
|
+
typeof network.configured === "boolean" &&
|
|
2373
|
+
Number.isSafeInteger(network.version) &&
|
|
2374
|
+
network.version >= 0 &&
|
|
2375
|
+
typeof network.ready === "boolean" &&
|
|
2376
|
+
(!network.configured || /^sha256:[a-f0-9]{64}$/.test(network.hash || "")),
|
|
2377
|
+
);
|
|
2378
|
+
if (
|
|
2379
|
+
response?.ok !== true ||
|
|
2380
|
+
result?.ready !== true ||
|
|
2381
|
+
result?.keystoreType !== "signer-owned-v2" ||
|
|
2382
|
+
result?.capabilities?.protocol?.current !== 2 ||
|
|
2383
|
+
result?.capabilities?.protocol?.min > 2 ||
|
|
2384
|
+
result?.capabilities?.protocol?.max < 2 ||
|
|
2385
|
+
result?.capabilities?.nativeFeeReservationLamports !== 5_000_000 ||
|
|
2386
|
+
result?.schema?.ready !== true ||
|
|
2387
|
+
(expectedReadOnly !== undefined && result?.readOnly !== expectedReadOnly) ||
|
|
2388
|
+
missing.length > 0 ||
|
|
2389
|
+
!policiesValid ||
|
|
2390
|
+
!networkValid ||
|
|
2391
|
+
!signerIdentitiesEqual(release, expectedIdentity)
|
|
2392
|
+
) {
|
|
2393
|
+
throw new Error(
|
|
2394
|
+
`Local signer failed protocol-v2 compatibility${missing.length ? `; missing ${missing.join(",")}` : ""}${expectedReadOnly !== undefined && result?.readOnly !== expectedReadOnly ? `; expected readOnly=${String(expectedReadOnly)}` : ""}`,
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
return result;
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
lastError = error;
|
|
2400
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
throw new Error(
|
|
2404
|
+
`Local signer did not become exactly healthy: ${lastError?.message || "timeout"}`,
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function localSignerStateInvariant(health) {
|
|
2409
|
+
return JSON.stringify({
|
|
2410
|
+
policies: [...(health?.policies || [])].toSorted((left, right) =>
|
|
2411
|
+
left.walletId.localeCompare(right.walletId),
|
|
2412
|
+
),
|
|
2413
|
+
network: {
|
|
2414
|
+
ready: health?.network?.ready,
|
|
2415
|
+
wallets: [...(health?.network?.wallets || [])].toSorted((left, right) =>
|
|
2416
|
+
left.walletId.localeCompare(right.walletId),
|
|
2417
|
+
),
|
|
2418
|
+
},
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
async function startSignerProcess(paths, binaryPath = paths.binaryPath, readOnly = false) {
|
|
2423
|
+
await fsp.mkdir(paths.materialDir, { recursive: true, mode: 0o700 });
|
|
2424
|
+
const log = await fsp.open(paths.logPath, "a", 0o600);
|
|
2425
|
+
const child = spawn(
|
|
2426
|
+
binaryPath,
|
|
2427
|
+
[
|
|
2428
|
+
"-socket",
|
|
2429
|
+
paths.socketPath,
|
|
2430
|
+
"-control-socket",
|
|
2431
|
+
paths.controlSocketPath,
|
|
2432
|
+
"-state-db",
|
|
2433
|
+
paths.stateDbPath,
|
|
2434
|
+
"-master-key",
|
|
2435
|
+
paths.masterKeyPath,
|
|
2436
|
+
"-pid-file",
|
|
2437
|
+
paths.pidPath,
|
|
2438
|
+
"-audit-log",
|
|
2439
|
+
paths.auditPath,
|
|
2440
|
+
...(readOnly ? ["-read-only"] : []),
|
|
2441
|
+
],
|
|
2442
|
+
{
|
|
2443
|
+
detached: true,
|
|
2444
|
+
stdio: ["ignore", log.fd, log.fd],
|
|
2445
|
+
env: await loadSignerEnvironment(paths),
|
|
2446
|
+
},
|
|
2447
|
+
);
|
|
2448
|
+
child.unref();
|
|
2449
|
+
await log.close();
|
|
2450
|
+
if (!Number.isSafeInteger(child.pid) || child.pid <= 1) {
|
|
2451
|
+
throw new Error("Could not start the exact Local signer candidate");
|
|
2452
|
+
}
|
|
2453
|
+
return child.pid;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
async function copyStandaloneFile(source, destination, mode) {
|
|
2457
|
+
await fsp.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
2458
|
+
await fsp.copyFile(source, destination);
|
|
2459
|
+
await fsp.chmod(destination, mode);
|
|
2460
|
+
await fsyncManagedPath(destination);
|
|
2461
|
+
await fsyncManagedPath(path.dirname(destination));
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
async function atomicInstallSignerBinary(paths, candidatePath, manifestPath) {
|
|
2465
|
+
await fsp.mkdir(paths.binDir, { recursive: true, mode: 0o700 });
|
|
2466
|
+
const candidateTemporary = `${paths.binaryPath}.candidate-${process.pid}-${Date.now()}`;
|
|
2467
|
+
await copyStandaloneFile(candidatePath, candidateTemporary, 0o700);
|
|
2468
|
+
await fsp.rename(candidateTemporary, paths.binaryPath);
|
|
2469
|
+
await fsyncManagedPath(paths.binDir);
|
|
2470
|
+
const releaseTemporary = `${paths.releaseManifestPath}.candidate-${process.pid}-${Date.now()}`;
|
|
2471
|
+
await copyStandaloneFile(manifestPath, releaseTemporary, 0o600);
|
|
2472
|
+
await fsp.rename(releaseTemporary, paths.releaseManifestPath);
|
|
2473
|
+
const enrollTemporary = `${paths.enrollmentPath}.candidate-${process.pid}-${Date.now()}`;
|
|
2474
|
+
await fsp.rm(enrollTemporary, { force: true });
|
|
2475
|
+
await fsp.link(paths.binaryPath, enrollTemporary);
|
|
2476
|
+
await fsp.rename(enrollTemporary, paths.enrollmentPath);
|
|
2477
|
+
await fsyncManagedPath(paths.binDir);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
async function snapshotStandaloneFile(source, destination) {
|
|
2481
|
+
try {
|
|
2482
|
+
const stat = await fsp.lstat(source);
|
|
2483
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
2484
|
+
throw new Error(`Signer installation path is not a regular file: ${source}`);
|
|
2485
|
+
}
|
|
2486
|
+
await copyStandaloneFile(source, destination, 0o600);
|
|
2487
|
+
return { existed: true, sha256: await sha256File(destination) };
|
|
2488
|
+
} catch (error) {
|
|
2489
|
+
if (error?.code === "ENOENT") {
|
|
2490
|
+
return { existed: false, sha256: null };
|
|
2491
|
+
}
|
|
2492
|
+
throw error;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
async function restoreStandaloneFile(snapshot, destination, executable = false) {
|
|
2497
|
+
if (!snapshot.existed) {
|
|
2498
|
+
await fsp.rm(destination, { force: true });
|
|
2499
|
+
await fsyncManagedPath(path.dirname(destination)).catch((error) => {
|
|
2500
|
+
if (error?.code !== "ENOENT") {
|
|
2501
|
+
throw error;
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
if ((await sha256File(snapshot.path)) !== snapshot.sha256) {
|
|
2507
|
+
throw new Error(`Signer rollback snapshot was tampered: ${snapshot.path}`);
|
|
2508
|
+
}
|
|
2509
|
+
const temporary = `${destination}.rollback-${process.pid}-${Date.now()}`;
|
|
2510
|
+
await copyStandaloneFile(snapshot.path, temporary, executable ? 0o700 : 0o600);
|
|
2511
|
+
await fsp.rename(temporary, destination);
|
|
2512
|
+
await fsyncManagedPath(path.dirname(destination));
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function validateLocalSignerJournal(paths, value) {
|
|
2516
|
+
if (
|
|
2517
|
+
!value ||
|
|
2518
|
+
typeof value !== "object" ||
|
|
2519
|
+
value.schemaVersion !== LOCAL_SIGNER_TRANSACTION_SCHEMA_VERSION ||
|
|
2520
|
+
!LOCAL_SIGNER_TRANSACTION_PHASES.has(value.phase) ||
|
|
2521
|
+
!TRANSACTION_ID_PATTERN.test(value.transactionId || "") ||
|
|
2522
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value.targetVersion || "") ||
|
|
2523
|
+
path.resolve(value.transactionDir || "") !==
|
|
2524
|
+
path.join(paths.transactionsDir, value.transactionId) ||
|
|
2525
|
+
!isPathInside(paths.updateRoot, value.transactionDir)
|
|
2526
|
+
) {
|
|
2527
|
+
throw new Error("Local signer update journal is invalid");
|
|
2528
|
+
}
|
|
2529
|
+
return value;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async function readLocalSignerJournal(paths) {
|
|
2533
|
+
try {
|
|
2534
|
+
return validateLocalSignerJournal(
|
|
2535
|
+
paths,
|
|
2536
|
+
JSON.parse(await fsp.readFile(paths.journalPath, "utf8")),
|
|
2537
|
+
);
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
if (error?.code === "ENOENT") {
|
|
2540
|
+
return null;
|
|
2541
|
+
}
|
|
2542
|
+
throw error;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
async function writeLocalSignerJournal(paths, journal, phase = journal.phase) {
|
|
2547
|
+
const next = validateLocalSignerJournal(paths, {
|
|
2548
|
+
...journal,
|
|
2549
|
+
schemaVersion: LOCAL_SIGNER_TRANSACTION_SCHEMA_VERSION,
|
|
2550
|
+
phase,
|
|
2551
|
+
updatedAt: new Date().toISOString(),
|
|
2552
|
+
});
|
|
2553
|
+
await atomicWriteJson(paths.journalPath, next, 0o600);
|
|
2554
|
+
await fsyncManagedPath(paths.journalPath);
|
|
2555
|
+
await fsyncManagedPath(paths.updateRoot);
|
|
2556
|
+
if (process.env.FASED_TEST_LOCAL_SIGNER_CRASH_AFTER_PHASE === phase) {
|
|
2557
|
+
process.exit(97);
|
|
2558
|
+
}
|
|
2559
|
+
return next;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
async function removeLocalSignerJournal(paths) {
|
|
2563
|
+
await fsp.rm(paths.journalPath, { force: true });
|
|
2564
|
+
await fsyncManagedPath(paths.updateRoot);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
async function hasLegacyLocalSignerMaterial(paths) {
|
|
2568
|
+
const stateDbExists = await fsp
|
|
2569
|
+
.lstat(paths.stateDbPath)
|
|
2570
|
+
.then((stat) => stat.isFile() && !stat.isSymbolicLink())
|
|
2571
|
+
.catch(() => false);
|
|
2572
|
+
if (stateDbExists) {
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
const entries = await fsp.readdir(paths.materialDir, { withFileTypes: true }).catch(() => []);
|
|
2576
|
+
return entries.some(
|
|
2577
|
+
(entry) =>
|
|
2578
|
+
entry.isFile() &&
|
|
2579
|
+
!entry.isSymbolicLink() &&
|
|
2580
|
+
(entry.name === "wallet-keys.json" ||
|
|
2581
|
+
/^keystore-(?:solana|evm)(?:-[A-Za-z0-9_-]+)?\.v1\.enc$/u.test(entry.name)),
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
async function prepareLocalSignerTransaction({
|
|
2586
|
+
targetVersion,
|
|
2587
|
+
expectedCommit,
|
|
2588
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
2589
|
+
deferCommit = false,
|
|
2590
|
+
confirmDowngrade,
|
|
2591
|
+
paths = resolveLocalSignerPaths(),
|
|
2592
|
+
}) {
|
|
2593
|
+
const existingJournal = await readLocalSignerJournal(paths);
|
|
2594
|
+
if (existingJournal) {
|
|
2595
|
+
throw new Error(
|
|
2596
|
+
`An unfinished Local signer transaction already exists (${existingJournal.transactionId}, phase=${existingJournal.phase}). Recover it first.`,
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
await fsp.mkdir(paths.transactionsDir, { recursive: true, mode: 0o700 });
|
|
2600
|
+
if (await hasLegacyLocalSignerMaterial(paths)) {
|
|
2601
|
+
throw new Error(
|
|
2602
|
+
[
|
|
2603
|
+
"A pre-v2 Local wallet must complete the one-time native signer migration before Fased can update.",
|
|
2604
|
+
"No process was stopped and no installed file or wallet state was changed.",
|
|
2605
|
+
"Run `fased wallet setup --mode local-signer-import` for the signer-only import command, then `fased wallet finalize-legacy-migration --wallet-id <wallet-id>` after verifying the exact public address.",
|
|
2606
|
+
"The passphrase must remain in an owner-only file read by fased-signerd; never put it in the Gateway, UI, argv, or an environment variable.",
|
|
2607
|
+
].join(" "),
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
const transactionId = randomUUID();
|
|
2611
|
+
const transactionDir = path.join(paths.transactionsDir, transactionId);
|
|
2612
|
+
await fsp.mkdir(transactionDir, { recursive: true, mode: 0o700 });
|
|
2613
|
+
const release = await downloadVerifiedLocalSignerRelease({
|
|
2614
|
+
targetVersion,
|
|
2615
|
+
timeoutMs,
|
|
2616
|
+
transactionDir,
|
|
2617
|
+
});
|
|
2618
|
+
if (expectedCommit && release.identity.commit !== expectedCommit) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
"Local signer release commit does not match the exact application release commit",
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
let previousIdentity = null;
|
|
2624
|
+
try {
|
|
2625
|
+
previousIdentity = await readSignerBinaryIdentity(paths.binaryPath);
|
|
2626
|
+
} catch (error) {
|
|
2627
|
+
if (error?.code !== "ENOENT" && !String(error.message).includes("could not execute")) {
|
|
2628
|
+
throw error;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
if (previousIdentity && compareVersions(previousIdentity.version, targetVersion) === 1) {
|
|
2632
|
+
if (confirmDowngrade !== targetVersion) {
|
|
2633
|
+
throw new Error(
|
|
2634
|
+
`Refusing signer downgrade ${previousIdentity.version} -> ${targetVersion}. Re-run only with --confirm-downgrade ${targetVersion} after reviewing the rollback boundary.`,
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const runningPid = await resolveRunningSignerPid(paths);
|
|
2639
|
+
let previousHealth = null;
|
|
2640
|
+
if (runningPid !== null) {
|
|
2641
|
+
if (!previousIdentity) {
|
|
2642
|
+
throw new Error("The active Local signer has no verifiable production release identity");
|
|
2643
|
+
}
|
|
2644
|
+
previousHealth = await probeLocalSignerHealth(paths.socketPath, previousIdentity, 5_000);
|
|
2645
|
+
}
|
|
2646
|
+
let journal = {
|
|
2647
|
+
schemaVersion: LOCAL_SIGNER_TRANSACTION_SCHEMA_VERSION,
|
|
2648
|
+
transactionId,
|
|
2649
|
+
transactionDir,
|
|
2650
|
+
targetVersion,
|
|
2651
|
+
targetIdentity: release.identity,
|
|
2652
|
+
previousIdentity,
|
|
2653
|
+
previousHealthPolicies: previousHealth?.policies || null,
|
|
2654
|
+
previousHealthInvariant: previousHealth ? localSignerStateInvariant(previousHealth) : null,
|
|
2655
|
+
previousWasRunning: runningPid !== null,
|
|
2656
|
+
legacyMigrationRequired: false,
|
|
2657
|
+
deferCommit: deferCommit,
|
|
2658
|
+
phase: "staging",
|
|
2659
|
+
createdAt: new Date().toISOString(),
|
|
2660
|
+
};
|
|
2661
|
+
journal = await writeLocalSignerJournal(paths, journal, "staging");
|
|
2662
|
+
await stopExactSigner(paths, runningPid);
|
|
2663
|
+
journal = await writeLocalSignerJournal(paths, journal, "quiesced");
|
|
2664
|
+
const snapshotDir = path.join(transactionDir, "snapshot");
|
|
2665
|
+
const materialSnapshotDir = path.join(snapshotDir, "material");
|
|
2666
|
+
await createOwnerOnlySnapshot(paths.materialDir, materialSnapshotDir, [
|
|
2667
|
+
paths.socketPath,
|
|
2668
|
+
paths.controlSocketPath,
|
|
2669
|
+
paths.pidPath,
|
|
2670
|
+
paths.legacyPidPath,
|
|
2671
|
+
paths.logPath,
|
|
2672
|
+
]);
|
|
2673
|
+
const binarySnapshotPath = path.join(snapshotDir, "fased-signerd.previous");
|
|
2674
|
+
const releaseSnapshotPath = path.join(snapshotDir, "fased-signerd-release.previous.json");
|
|
2675
|
+
const enrollmentSnapshotPath = path.join(snapshotDir, "fased-signer-enroll.previous");
|
|
2676
|
+
const binarySnapshot = await snapshotStandaloneFile(paths.binaryPath, binarySnapshotPath);
|
|
2677
|
+
const releaseSnapshot = await snapshotStandaloneFile(
|
|
2678
|
+
paths.releaseManifestPath,
|
|
2679
|
+
releaseSnapshotPath,
|
|
2680
|
+
);
|
|
2681
|
+
const enrollmentSnapshot = await snapshotStandaloneFile(
|
|
2682
|
+
paths.enrollmentPath,
|
|
2683
|
+
enrollmentSnapshotPath,
|
|
2684
|
+
);
|
|
2685
|
+
const auxiliarySnapshots = [];
|
|
2686
|
+
const auxiliaryDestinations = [
|
|
2687
|
+
{ destination: paths.policyHelperPath, executable: true },
|
|
2688
|
+
{ destination: paths.policyLauncherPath, executable: true },
|
|
2689
|
+
...paths.policyTemplatePaths.map((destination) => ({ destination, executable: false })),
|
|
2690
|
+
];
|
|
2691
|
+
for (const [index, entry] of auxiliaryDestinations.entries()) {
|
|
2692
|
+
const snapshotPath = path.join(
|
|
2693
|
+
snapshotDir,
|
|
2694
|
+
"auxiliary",
|
|
2695
|
+
`${index}-${path.basename(entry.destination)}.previous`,
|
|
2696
|
+
);
|
|
2697
|
+
auxiliarySnapshots.push({
|
|
2698
|
+
...entry,
|
|
2699
|
+
...(await snapshotStandaloneFile(entry.destination, snapshotPath)),
|
|
2700
|
+
path: snapshotPath,
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
journal = {
|
|
2704
|
+
...journal,
|
|
2705
|
+
snapshot: {
|
|
2706
|
+
materialDir: materialSnapshotDir,
|
|
2707
|
+
binary: { ...binarySnapshot, path: binarySnapshotPath },
|
|
2708
|
+
releaseManifest: { ...releaseSnapshot, path: releaseSnapshotPath },
|
|
2709
|
+
enrollment: { ...enrollmentSnapshot, path: enrollmentSnapshotPath },
|
|
2710
|
+
auxiliary: auxiliarySnapshots,
|
|
2711
|
+
},
|
|
2712
|
+
candidatePath: release.candidatePath,
|
|
2713
|
+
candidateManifestPath: release.manifestPath,
|
|
2714
|
+
};
|
|
2715
|
+
journal = await writeLocalSignerJournal(paths, journal, "snapshotted");
|
|
2716
|
+
|
|
2717
|
+
const preflightMaterial = path.join(transactionDir, "preflight", "material");
|
|
2718
|
+
await restoreOwnerOnlySnapshot(materialSnapshotDir, preflightMaterial);
|
|
2719
|
+
const translate = (source) =>
|
|
2720
|
+
path.join(preflightMaterial, path.relative(paths.materialDir, source));
|
|
2721
|
+
const preflightRuntime = await fsp.mkdtemp(path.join(os.tmpdir(), "fased-signerd-preflight-"));
|
|
2722
|
+
await fsp.chmod(preflightRuntime, 0o700);
|
|
2723
|
+
const preflightPaths = {
|
|
2724
|
+
...paths,
|
|
2725
|
+
binaryPath: release.candidatePath,
|
|
2726
|
+
materialDir: preflightMaterial,
|
|
2727
|
+
socketPath: path.join(preflightRuntime, "app.sock"),
|
|
2728
|
+
controlSocketPath: path.join(preflightRuntime, "control.sock"),
|
|
2729
|
+
stateDbPath: translate(paths.stateDbPath),
|
|
2730
|
+
masterKeyPath: translate(paths.masterKeyPath),
|
|
2731
|
+
pidPath: path.join(preflightRuntime, "signer.pid"),
|
|
2732
|
+
legacyPidPath: path.join(preflightRuntime, "legacy.pid"),
|
|
2733
|
+
auditPath: path.join(preflightMaterial, "preflight.audit.jsonl"),
|
|
2734
|
+
logPath: path.join(preflightMaterial, "preflight.log"),
|
|
2735
|
+
signerEnvPath: translate(paths.signerEnvPath),
|
|
2736
|
+
};
|
|
2737
|
+
const preflightPid = await startSignerProcess(preflightPaths, release.candidatePath, true);
|
|
2738
|
+
try {
|
|
2739
|
+
const candidateHealth = await probeLocalSignerHealth(
|
|
2740
|
+
preflightPaths.socketPath,
|
|
2741
|
+
release.identity,
|
|
2742
|
+
10_000,
|
|
2743
|
+
true,
|
|
2744
|
+
);
|
|
2745
|
+
if (
|
|
2746
|
+
journal.previousHealthInvariant &&
|
|
2747
|
+
localSignerStateInvariant(candidateHealth) !== journal.previousHealthInvariant
|
|
2748
|
+
) {
|
|
2749
|
+
throw new Error(
|
|
2750
|
+
"Candidate signer preflight did not preserve exact wallet, policy, and network hashes",
|
|
2751
|
+
);
|
|
2752
|
+
}
|
|
2753
|
+
} finally {
|
|
2754
|
+
await stopExactSigner(preflightPaths, preflightPid).catch(() => undefined);
|
|
2755
|
+
await fsp.rm(preflightRuntime, { recursive: true, force: true });
|
|
2756
|
+
}
|
|
2757
|
+
journal = await writeLocalSignerJournal(paths, journal, "prepared");
|
|
2758
|
+
return journal;
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
async function activateLocalSignerTransaction(paths = resolveLocalSignerPaths()) {
|
|
2762
|
+
let journal = await readLocalSignerJournal(paths);
|
|
2763
|
+
if (!journal || journal.phase !== "prepared") {
|
|
2764
|
+
throw new Error("Local signer transaction is not prepared for activation");
|
|
2765
|
+
}
|
|
2766
|
+
journal = await writeLocalSignerJournal(paths, journal, "activating");
|
|
2767
|
+
await stopExactSigner(paths);
|
|
2768
|
+
await atomicInstallSignerBinary(paths, journal.candidatePath, journal.candidateManifestPath);
|
|
2769
|
+
const stateExists = await fsp
|
|
2770
|
+
.lstat(paths.stateDbPath)
|
|
2771
|
+
.then((stat) => stat.isFile())
|
|
2772
|
+
.catch(() => false);
|
|
2773
|
+
const shouldStart =
|
|
2774
|
+
!journal.legacyMigrationRequired &&
|
|
2775
|
+
(journal.previousWasRunning || stateExists || process.env.FASED_LOCAL_SIGNER_START === "1");
|
|
2776
|
+
if (shouldStart) {
|
|
2777
|
+
const pid = await startSignerProcess(paths, paths.binaryPath, true);
|
|
2778
|
+
try {
|
|
2779
|
+
const health = await probeLocalSignerHealth(
|
|
2780
|
+
paths.socketPath,
|
|
2781
|
+
journal.targetIdentity,
|
|
2782
|
+
15_000,
|
|
2783
|
+
true,
|
|
2784
|
+
);
|
|
2785
|
+
if (
|
|
2786
|
+
journal.previousHealthInvariant &&
|
|
2787
|
+
localSignerStateInvariant(health) !== journal.previousHealthInvariant
|
|
2788
|
+
) {
|
|
2789
|
+
throw new Error(
|
|
2790
|
+
"Activated signer did not preserve exact wallet, policy, and network hashes",
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
} catch (error) {
|
|
2794
|
+
await stopExactSigner(paths, pid).catch(() => undefined);
|
|
2795
|
+
throw error;
|
|
2796
|
+
}
|
|
2797
|
+
} else {
|
|
2798
|
+
const exact = await readSignerBinaryIdentity(paths.binaryPath, journal.targetVersion);
|
|
2799
|
+
if (!signerIdentitiesEqual(exact, journal.targetIdentity)) {
|
|
2800
|
+
throw new Error("Activated Local signer binary identity changed during the switch");
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
journal = await writeLocalSignerJournal(
|
|
2804
|
+
paths,
|
|
2805
|
+
{ ...journal, candidateShouldRun: shouldStart },
|
|
2806
|
+
"candidate-active",
|
|
2807
|
+
);
|
|
2808
|
+
return journal;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
async function commitLocalSignerTransaction(paths = resolveLocalSignerPaths()) {
|
|
2812
|
+
let journal = await readLocalSignerJournal(paths);
|
|
2813
|
+
if (!journal || !new Set(["candidate-active", "committing"]).has(journal.phase)) {
|
|
2814
|
+
throw new Error("Local signer transaction has not passed candidate health verification");
|
|
2815
|
+
}
|
|
2816
|
+
if (journal.phase !== "committing") {
|
|
2817
|
+
journal = await writeLocalSignerJournal(paths, journal, "committing");
|
|
2818
|
+
}
|
|
2819
|
+
const exact = await readSignerBinaryIdentity(paths.binaryPath, journal.targetVersion);
|
|
2820
|
+
if (!signerIdentitiesEqual(exact, journal.targetIdentity)) {
|
|
2821
|
+
throw new Error("Local signer commit identity no longer matches the verified candidate");
|
|
2822
|
+
}
|
|
2823
|
+
if (journal.candidateShouldRun) {
|
|
2824
|
+
await stopExactSigner(paths);
|
|
2825
|
+
await startSignerProcess(paths);
|
|
2826
|
+
const health = await probeLocalSignerHealth(paths.socketPath, exact, 15_000, false);
|
|
2827
|
+
if (
|
|
2828
|
+
journal.previousHealthInvariant &&
|
|
2829
|
+
localSignerStateInvariant(health) !== journal.previousHealthInvariant
|
|
2830
|
+
) {
|
|
2831
|
+
throw new Error("Committed signer did not preserve exact wallet, policy, and network hashes");
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
const committedMarker = path.join(journal.transactionDir, "committed.json");
|
|
2835
|
+
await atomicWriteJson(
|
|
2836
|
+
committedMarker,
|
|
2837
|
+
{ schemaVersion: 1, committedAt: new Date().toISOString(), identity: exact },
|
|
2838
|
+
0o600,
|
|
2839
|
+
);
|
|
2840
|
+
await fsyncManagedPath(committedMarker);
|
|
2841
|
+
await fsyncManagedPath(journal.transactionDir);
|
|
2842
|
+
await removeLocalSignerJournal(paths);
|
|
2843
|
+
const transactions = await fsp.readdir(paths.transactionsDir, { withFileTypes: true });
|
|
2844
|
+
for (const entry of transactions) {
|
|
2845
|
+
if (entry.isDirectory() && entry.name !== journal.transactionId) {
|
|
2846
|
+
await fsp.rm(path.join(paths.transactionsDir, entry.name), {
|
|
2847
|
+
recursive: true,
|
|
2848
|
+
force: true,
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
await fsyncManagedPath(paths.transactionsDir);
|
|
2853
|
+
return {
|
|
2854
|
+
action: "committed",
|
|
2855
|
+
identity: exact,
|
|
2856
|
+
legacyMigrationRequired: journal.legacyMigrationRequired === true,
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
async function rollbackLocalSignerTransaction(paths = resolveLocalSignerPaths(), originalError) {
|
|
2861
|
+
let journal = await readLocalSignerJournal(paths);
|
|
2862
|
+
if (!journal) {
|
|
2863
|
+
return { action: "none" };
|
|
2864
|
+
}
|
|
2865
|
+
if (journal.phase === "committing") {
|
|
2866
|
+
throw new Error(
|
|
2867
|
+
"Local signer has a durable commit decision; rollback is refused and recovery must finish forward",
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
if (journal.phase !== "rolling-back") {
|
|
2871
|
+
journal = await writeLocalSignerJournal(paths, journal, "rolling-back");
|
|
2872
|
+
}
|
|
2873
|
+
await stopExactSigner(paths).catch(async (error) => {
|
|
2874
|
+
if (journal.phase === "staging") {
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
throw error;
|
|
2878
|
+
});
|
|
2879
|
+
if (journal.snapshot) {
|
|
2880
|
+
await restoreOwnerOnlySnapshot(journal.snapshot.materialDir, paths.materialDir);
|
|
2881
|
+
await fsp.mkdir(paths.binDir, { recursive: true, mode: 0o700 });
|
|
2882
|
+
await restoreStandaloneFile(journal.snapshot.binary, paths.binaryPath, true);
|
|
2883
|
+
await restoreStandaloneFile(journal.snapshot.releaseManifest, paths.releaseManifestPath, false);
|
|
2884
|
+
await restoreStandaloneFile(journal.snapshot.enrollment, paths.enrollmentPath, true);
|
|
2885
|
+
for (const entry of journal.snapshot.auxiliary || []) {
|
|
2886
|
+
await restoreStandaloneFile(entry, entry.destination, entry.executable === true);
|
|
2887
|
+
}
|
|
2888
|
+
await fsyncManagedPath(paths.binDir);
|
|
2889
|
+
if (journal.previousWasRunning && journal.snapshot.binary.existed) {
|
|
2890
|
+
const previousIdentity = await readSignerBinaryIdentity(paths.binaryPath);
|
|
2891
|
+
if (
|
|
2892
|
+
journal.previousIdentity &&
|
|
2893
|
+
!signerIdentitiesEqual(previousIdentity, journal.previousIdentity)
|
|
2894
|
+
) {
|
|
2895
|
+
throw new Error("Rollback restored a different Local signer binary identity");
|
|
2896
|
+
}
|
|
2897
|
+
await startSignerProcess(paths);
|
|
2898
|
+
if (journal.previousIdentity) {
|
|
2899
|
+
await probeLocalSignerHealth(paths.socketPath, journal.previousIdentity, 15_000);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
} else if (journal.previousWasRunning) {
|
|
2903
|
+
await startSignerProcess(paths);
|
|
2904
|
+
}
|
|
2905
|
+
await removeLocalSignerJournal(paths);
|
|
2906
|
+
await fsp.rm(journal.transactionDir, { recursive: true, force: true });
|
|
2907
|
+
await fsyncManagedPath(paths.updateRoot);
|
|
2908
|
+
if (originalError) {
|
|
2909
|
+
const error = new Error(`Local signer update rolled back exactly: ${originalError.message}`, {
|
|
2910
|
+
cause: originalError,
|
|
2911
|
+
});
|
|
2912
|
+
error.code = "LOCAL_SIGNER_UPDATE_ROLLED_BACK";
|
|
2913
|
+
throw error;
|
|
2914
|
+
}
|
|
2915
|
+
return { action: "rolled-back" };
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
async function recoverLocalSignerTransaction(paths = resolveLocalSignerPaths()) {
|
|
2919
|
+
const journal = await readLocalSignerJournal(paths);
|
|
2920
|
+
if (!journal) {
|
|
2921
|
+
return { action: "none" };
|
|
2922
|
+
}
|
|
2923
|
+
if (journal.phase === "committing") {
|
|
2924
|
+
return await commitLocalSignerTransaction(paths);
|
|
2925
|
+
}
|
|
2926
|
+
if (journal.phase === "candidate-active" && !journal.deferCommit) {
|
|
2927
|
+
return await commitLocalSignerTransaction(paths);
|
|
2928
|
+
}
|
|
2929
|
+
return await rollbackLocalSignerTransaction(paths);
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
async function runLocalSignerTransaction(options, pathOverrides = {}) {
|
|
2933
|
+
const paths = resolveLocalSignerPaths(pathOverrides);
|
|
2934
|
+
await fsp.mkdir(paths.updateRoot, { recursive: true, mode: 0o700 });
|
|
2935
|
+
const releaseLock = await acquireUpdateLock(paths.updateRoot);
|
|
2936
|
+
try {
|
|
2937
|
+
if (options.action === "status") {
|
|
2938
|
+
return { action: "status", journal: await readLocalSignerJournal(paths) };
|
|
2939
|
+
}
|
|
2940
|
+
if (options.action === "verify") {
|
|
2941
|
+
const journal = await readLocalSignerJournal(paths);
|
|
2942
|
+
const identity = await readSignerBinaryIdentity(
|
|
2943
|
+
paths.binaryPath,
|
|
2944
|
+
options.targetVersion || undefined,
|
|
2945
|
+
);
|
|
2946
|
+
if (options.expectedCommit && identity.commit !== options.expectedCommit) {
|
|
2947
|
+
throw new Error(
|
|
2948
|
+
"Running Local signer commit does not match the exact application release commit",
|
|
2949
|
+
);
|
|
2950
|
+
}
|
|
2951
|
+
await probeLocalSignerHealth(
|
|
2952
|
+
paths.socketPath,
|
|
2953
|
+
identity,
|
|
2954
|
+
Math.min(options.timeoutMs, 15_000),
|
|
2955
|
+
journal?.phase === "candidate-active",
|
|
2956
|
+
);
|
|
2957
|
+
return { action: "verified", identity };
|
|
2958
|
+
}
|
|
2959
|
+
if (options.action === "recover") {
|
|
2960
|
+
return await recoverLocalSignerTransaction(paths);
|
|
2961
|
+
}
|
|
2962
|
+
if (options.action === "rollback") {
|
|
2963
|
+
return await rollbackLocalSignerTransaction(paths);
|
|
2964
|
+
}
|
|
2965
|
+
if (options.action === "commit") {
|
|
2966
|
+
return await commitLocalSignerTransaction(paths);
|
|
2967
|
+
}
|
|
2968
|
+
if (options.action === "activate") {
|
|
2969
|
+
return await activateLocalSignerTransaction(paths);
|
|
2970
|
+
}
|
|
2971
|
+
if (options.action === "prepare") {
|
|
2972
|
+
return await prepareLocalSignerTransaction({ ...options, paths });
|
|
2973
|
+
}
|
|
2974
|
+
if (options.action !== "install") {
|
|
2975
|
+
throw new Error(`Unsupported Local signer transaction action: ${options.action}`);
|
|
2976
|
+
}
|
|
2977
|
+
await recoverLocalSignerTransaction(paths);
|
|
2978
|
+
try {
|
|
2979
|
+
await prepareLocalSignerTransaction({ ...options, paths });
|
|
2980
|
+
const journal = await activateLocalSignerTransaction(paths);
|
|
2981
|
+
if (options.deferCommit) {
|
|
2982
|
+
return { action: "candidate-active", identity: journal.targetIdentity };
|
|
2983
|
+
}
|
|
2984
|
+
return await commitLocalSignerTransaction(paths);
|
|
2985
|
+
} catch (error) {
|
|
2986
|
+
if (await readLocalSignerJournal(paths)) {
|
|
2987
|
+
return await rollbackLocalSignerTransaction(paths, error);
|
|
2988
|
+
}
|
|
2989
|
+
throw error;
|
|
2990
|
+
}
|
|
2991
|
+
} finally {
|
|
2992
|
+
await releaseLock();
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function parseLocalSignerTransactionArgs(argv) {
|
|
2997
|
+
const action = argv[1] || "install";
|
|
2998
|
+
if (
|
|
2999
|
+
!new Set([
|
|
3000
|
+
"install",
|
|
3001
|
+
"prepare",
|
|
3002
|
+
"activate",
|
|
3003
|
+
"verify",
|
|
3004
|
+
"commit",
|
|
3005
|
+
"rollback",
|
|
3006
|
+
"recover",
|
|
3007
|
+
"status",
|
|
3008
|
+
]).has(action)
|
|
3009
|
+
) {
|
|
3010
|
+
throw new Error(
|
|
3011
|
+
"local-signer requires install, prepare, activate, verify, commit, rollback, recover, or status",
|
|
3012
|
+
);
|
|
3013
|
+
}
|
|
3014
|
+
const options = {
|
|
3015
|
+
action,
|
|
3016
|
+
targetVersion: null,
|
|
3017
|
+
expectedCommit: null,
|
|
3018
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
3019
|
+
deferCommit: false,
|
|
3020
|
+
confirmDowngrade: null,
|
|
3021
|
+
};
|
|
3022
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
3023
|
+
const token = argv[index];
|
|
3024
|
+
if (token === "--version") {
|
|
3025
|
+
options.targetVersion = String(argv[++index] || "").replace(/^v/, "");
|
|
3026
|
+
} else if (token === "--expected-commit") {
|
|
3027
|
+
options.expectedCommit = String(argv[++index] || "")
|
|
3028
|
+
.trim()
|
|
3029
|
+
.toLowerCase();
|
|
3030
|
+
} else if (token === "--timeout") {
|
|
3031
|
+
options.timeoutMs = Number.parseInt(argv[++index] || "", 10) * 1000;
|
|
3032
|
+
} else if (token === "--defer-commit") {
|
|
3033
|
+
options.deferCommit = true;
|
|
3034
|
+
} else if (token === "--confirm-downgrade") {
|
|
3035
|
+
options.confirmDowngrade = String(argv[++index] || "").replace(/^v/, "");
|
|
3036
|
+
} else {
|
|
3037
|
+
throw new Error(`Unsupported Local signer transaction option: ${token}`);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
if (
|
|
3041
|
+
new Set(["install", "prepare", "verify"]).has(action) &&
|
|
3042
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(options.targetVersion || "")
|
|
3043
|
+
) {
|
|
3044
|
+
throw new Error(`local-signer ${action} requires --version X.Y.Z`);
|
|
3045
|
+
}
|
|
3046
|
+
if (options.expectedCommit !== null && !/^[a-f0-9]{40}$/.test(options.expectedCommit)) {
|
|
3047
|
+
throw new Error("--expected-commit must be one exact 40-character Git commit");
|
|
3048
|
+
}
|
|
3049
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
3050
|
+
throw new Error("--timeout must be a positive integer (seconds)");
|
|
3051
|
+
}
|
|
3052
|
+
return options;
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
function localPairedUpdateJournalPath(paths) {
|
|
3056
|
+
return path.join(paths.stateDir, "local-paired-update-transaction.json");
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
function validateLocalPairedUpdateJournal(paths, value) {
|
|
3060
|
+
if (
|
|
3061
|
+
!value ||
|
|
3062
|
+
typeof value !== "object" ||
|
|
3063
|
+
value.schemaVersion !== 1 ||
|
|
3064
|
+
!new Set(["prepared", "signer-active", "app-active", "gateway-verified"]).has(value.phase) ||
|
|
3065
|
+
!TRANSACTION_ID_PATTERN.test(value.transactionId || "") ||
|
|
3066
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value.targetVersion || "") ||
|
|
3067
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value.previousVersion || "") ||
|
|
3068
|
+
value.nextManifest?.runtime?.activeVersion !== value.targetVersion ||
|
|
3069
|
+
value.previousManifest?.runtime?.activeVersion !== value.previousVersion ||
|
|
3070
|
+
value.nextManifest?.profile === "hosting" ||
|
|
3071
|
+
value.previousManifest?.profile === "hosting"
|
|
3072
|
+
) {
|
|
3073
|
+
throw new Error("Local paired app/signer update journal is invalid");
|
|
3074
|
+
}
|
|
3075
|
+
return {
|
|
3076
|
+
...value,
|
|
3077
|
+
targetRoot: managedReleaseRoot(paths, value.targetRoot, "targetRoot"),
|
|
3078
|
+
previousRoot: managedReleaseRoot(paths, value.previousRoot, "previousRoot"),
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
async function readLocalPairedUpdateJournal(paths) {
|
|
3083
|
+
try {
|
|
3084
|
+
return validateLocalPairedUpdateJournal(
|
|
3085
|
+
paths,
|
|
3086
|
+
JSON.parse(await fsp.readFile(localPairedUpdateJournalPath(paths), "utf8")),
|
|
3087
|
+
);
|
|
3088
|
+
} catch (error) {
|
|
3089
|
+
if (error?.code === "ENOENT") {
|
|
3090
|
+
return null;
|
|
3091
|
+
}
|
|
3092
|
+
throw error;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
async function writeLocalPairedUpdateJournal(paths, journal, phase = journal.phase) {
|
|
3097
|
+
const next = validateLocalPairedUpdateJournal(paths, {
|
|
3098
|
+
...journal,
|
|
3099
|
+
schemaVersion: 1,
|
|
3100
|
+
phase,
|
|
3101
|
+
updatedAt: new Date().toISOString(),
|
|
3102
|
+
});
|
|
3103
|
+
const journalPath = localPairedUpdateJournalPath(paths);
|
|
3104
|
+
await atomicWriteJson(journalPath, next, 0o600);
|
|
3105
|
+
await fsyncManagedPath(journalPath);
|
|
3106
|
+
await fsyncManagedPath(paths.stateDir);
|
|
3107
|
+
if (process.env.FASED_TEST_LOCAL_PAIRED_CRASH_AFTER_PHASE === phase) {
|
|
3108
|
+
process.exit(98);
|
|
3109
|
+
}
|
|
3110
|
+
return next;
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
async function removeLocalPairedUpdateJournal(paths) {
|
|
3114
|
+
await fsp.rm(localPairedUpdateJournalPath(paths), { force: true });
|
|
3115
|
+
await fsyncManagedPath(paths.stateDir);
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
async function localSignerIsInstalledOrConfigured(signerPaths) {
|
|
3119
|
+
for (const candidate of [signerPaths.binaryPath, signerPaths.stateDbPath]) {
|
|
3120
|
+
if (
|
|
3121
|
+
await fsp
|
|
3122
|
+
.lstat(candidate)
|
|
3123
|
+
.then((stat) => stat.isFile() && !stat.isSymbolicLink())
|
|
3124
|
+
.catch(() => false)
|
|
3125
|
+
) {
|
|
3126
|
+
return true;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
if (await hasLegacyLocalSignerMaterial(signerPaths)) {
|
|
3130
|
+
return true;
|
|
3131
|
+
}
|
|
3132
|
+
const registryPath = path.join(signerPaths.materialDir, "provider-registry.v1.json");
|
|
3133
|
+
try {
|
|
3134
|
+
const registry = JSON.parse(await fsp.readFile(registryPath, "utf8"));
|
|
3135
|
+
return (registry.wallets || []).some((wallet) => wallet?.providerId === "local-socket-signer");
|
|
3136
|
+
} catch {
|
|
3137
|
+
return false;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
async function restoreLocalPairedApplication(paths, journal) {
|
|
3142
|
+
await atomicSymlink(journal.previousRoot, paths.currentLink);
|
|
3143
|
+
await replaceCompatibilityLink(paths);
|
|
3144
|
+
await atomicWriteJson(paths.manifestPath, journal.previousManifest, 0o600);
|
|
3145
|
+
await syncManagedActivation(paths);
|
|
3146
|
+
await updateStableComponents(paths, journal.previousRoot, true);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
async function rollbackLocalPairedUpdate(paths, journal, timeoutMs, originalError = null) {
|
|
3150
|
+
const failures = [];
|
|
3151
|
+
try {
|
|
3152
|
+
await restoreLocalPairedApplication(paths, journal);
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
failures.push(`application restore: ${error.message}`);
|
|
3155
|
+
}
|
|
3156
|
+
try {
|
|
3157
|
+
await runLocalSignerTransaction({ action: "rollback" }, { stateDir: paths.stateDir });
|
|
3158
|
+
} catch (error) {
|
|
3159
|
+
failures.push(`signer restore: ${error.message}`);
|
|
3160
|
+
}
|
|
3161
|
+
if (failures.length === 0) {
|
|
3162
|
+
try {
|
|
3163
|
+
await refreshGateway(
|
|
3164
|
+
journal.previousRoot,
|
|
3165
|
+
journal.previousManifest,
|
|
3166
|
+
timeoutMs,
|
|
3167
|
+
false,
|
|
3168
|
+
journal.previousManifest?.signer?.release,
|
|
3169
|
+
);
|
|
3170
|
+
} catch (error) {
|
|
3171
|
+
failures.push(`previous Gateway refresh: ${error.message}`);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (failures.length > 0) {
|
|
3175
|
+
const error = new Error(
|
|
3176
|
+
`Local paired update recovery is incomplete (${failures.join("; ")}). Re-run fased update after correcting the failure.`,
|
|
3177
|
+
{ cause: originalError || undefined },
|
|
3178
|
+
);
|
|
3179
|
+
error.code = "LOCAL_PAIRED_ROLLBACK_INCOMPLETE";
|
|
3180
|
+
throw error;
|
|
3181
|
+
}
|
|
3182
|
+
await removeLocalPairedUpdateJournal(paths);
|
|
3183
|
+
if (originalError) {
|
|
3184
|
+
const error = new Error(
|
|
3185
|
+
`Update rolled back the exact Local app and signer after health verification failed: ${originalError.message}`,
|
|
3186
|
+
{ cause: originalError },
|
|
3187
|
+
);
|
|
3188
|
+
error.code = "LOCAL_PAIRED_UPDATE_ROLLED_BACK";
|
|
3189
|
+
throw error;
|
|
3190
|
+
}
|
|
3191
|
+
return { action: "rolled-back" };
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
async function recoverLocalPairedUpdate(paths, timeoutMs) {
|
|
3195
|
+
const journal = await readLocalPairedUpdateJournal(paths);
|
|
3196
|
+
if (!journal) {
|
|
3197
|
+
await runLocalSignerTransaction({ action: "recover" }, { stateDir: paths.stateDir });
|
|
3198
|
+
return { recovered: false };
|
|
3199
|
+
}
|
|
3200
|
+
if (journal.phase === "gateway-verified") {
|
|
3201
|
+
let signer;
|
|
3202
|
+
try {
|
|
3203
|
+
signer = await runLocalSignerTransaction({ action: "commit" }, { stateDir: paths.stateDir });
|
|
3204
|
+
} catch (error) {
|
|
3205
|
+
if (!String(error?.message || "").includes("has not passed candidate health")) {
|
|
3206
|
+
throw error;
|
|
3207
|
+
}
|
|
3208
|
+
const signerPaths = resolveLocalSignerPaths({ stateDir: paths.stateDir });
|
|
3209
|
+
const identity = await readSignerBinaryIdentity(
|
|
3210
|
+
signerPaths.binaryPath,
|
|
3211
|
+
journal.targetVersion,
|
|
3212
|
+
);
|
|
3213
|
+
if (!signerIdentitiesEqual(identity, journal.nextManifest?.signer?.release)) {
|
|
3214
|
+
throw new Error("Local paired recovery found no signer journal and a mismatched binary", {
|
|
3215
|
+
cause: error,
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
await probeLocalSignerHealth(signerPaths.socketPath, identity, Math.min(timeoutMs, 15_000));
|
|
3219
|
+
signer = { identity };
|
|
3220
|
+
}
|
|
3221
|
+
const nextManifest = {
|
|
3222
|
+
...journal.nextManifest,
|
|
3223
|
+
signer: { release: signer.identity },
|
|
3224
|
+
};
|
|
3225
|
+
await atomicWriteJson(paths.manifestPath, nextManifest, 0o600);
|
|
3226
|
+
await syncManagedActivation(paths);
|
|
3227
|
+
await updateStableComponents(paths, journal.targetRoot, true);
|
|
3228
|
+
await removeLocalPairedUpdateJournal(paths);
|
|
3229
|
+
return { recovered: true, action: "committed" };
|
|
3230
|
+
}
|
|
3231
|
+
return {
|
|
3232
|
+
recovered: true,
|
|
3233
|
+
...(await rollbackLocalPairedUpdate(paths, journal, timeoutMs)),
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
async function updateManagedRuntime(options) {
|
|
3238
|
+
const commandStartedAt = Date.now();
|
|
3239
|
+
const timings = [];
|
|
3240
|
+
const paths = resolveManagedRuntimePaths();
|
|
3241
|
+
let existingManifest = readManagedInstallManifest(paths.manifestPath);
|
|
3242
|
+
if (!existingManifest) {
|
|
3243
|
+
throw new Error(
|
|
3244
|
+
"Managed installation manifest is missing; run the official repair installer once.",
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
let currentRoot = await resolveLinkTarget(paths.currentLink);
|
|
3248
|
+
let currentVersion = currentRoot
|
|
3249
|
+
? (await readPackageVersion(currentRoot)) || existingManifest.runtime.activeVersion
|
|
3250
|
+
: existingManifest.runtime.activeVersion;
|
|
3251
|
+
let targetVersion;
|
|
3252
|
+
|
|
3253
|
+
if (options.status || options.dryRun) {
|
|
3254
|
+
targetVersion = await measureStage(timings, "release resolution", () =>
|
|
3255
|
+
resolveTargetVersion(options),
|
|
3256
|
+
);
|
|
3257
|
+
const available = compareVersions(currentVersion, targetVersion) === -1;
|
|
3258
|
+
const result = {
|
|
3259
|
+
install: `managed ${existingManifest.profile}`,
|
|
618
3260
|
currentVersion,
|
|
619
3261
|
targetVersion,
|
|
620
3262
|
available,
|
|
@@ -638,6 +3280,34 @@ async function updateManagedRuntime(options) {
|
|
|
638
3280
|
|
|
639
3281
|
const releaseLock = await acquireUpdateLock(paths.stateDir);
|
|
640
3282
|
try {
|
|
3283
|
+
if (existingManifest.profile === "hosting") {
|
|
3284
|
+
await measureStage(timings, "interrupted hosted transaction recovery", () =>
|
|
3285
|
+
recoverHostedReleaseTransaction(paths, options.timeoutMs),
|
|
3286
|
+
);
|
|
3287
|
+
existingManifest = readManagedInstallManifest(paths.manifestPath);
|
|
3288
|
+
if (!existingManifest) {
|
|
3289
|
+
throw new Error("Managed installation manifest is invalid after hosted update recovery.");
|
|
3290
|
+
}
|
|
3291
|
+
currentRoot = await resolveLinkTarget(paths.currentLink);
|
|
3292
|
+
currentVersion = currentRoot
|
|
3293
|
+
? (await readPackageVersion(currentRoot)) || existingManifest.runtime.activeVersion
|
|
3294
|
+
: existingManifest.runtime.activeVersion;
|
|
3295
|
+
} else {
|
|
3296
|
+
await measureStage(timings, "interrupted Local app/signer transaction recovery", () =>
|
|
3297
|
+
recoverLocalPairedUpdate(paths, options.timeoutMs),
|
|
3298
|
+
);
|
|
3299
|
+
existingManifest = readManagedInstallManifest(paths.manifestPath);
|
|
3300
|
+
if (!existingManifest) {
|
|
3301
|
+
throw new Error("Managed installation manifest is invalid after Local update recovery.");
|
|
3302
|
+
}
|
|
3303
|
+
currentRoot = await resolveLinkTarget(paths.currentLink);
|
|
3304
|
+
currentVersion = currentRoot
|
|
3305
|
+
? (await readPackageVersion(currentRoot)) || existingManifest.runtime.activeVersion
|
|
3306
|
+
: existingManifest.runtime.activeVersion;
|
|
3307
|
+
}
|
|
3308
|
+
targetVersion = await measureStage(timings, "release resolution", () =>
|
|
3309
|
+
resolveTargetVersion(options),
|
|
3310
|
+
);
|
|
641
3311
|
const comparison = compareVersions(currentVersion, targetVersion);
|
|
642
3312
|
let repairCurrentFiles = false;
|
|
643
3313
|
if (comparison === 0) {
|
|
@@ -656,7 +3326,13 @@ async function updateManagedRuntime(options) {
|
|
|
656
3326
|
2000,
|
|
657
3327
|
);
|
|
658
3328
|
if (!identity.ok && options.restart && currentRoot) {
|
|
659
|
-
await refreshGateway(
|
|
3329
|
+
await refreshGateway(
|
|
3330
|
+
currentRoot,
|
|
3331
|
+
existingManifest,
|
|
3332
|
+
options.timeoutMs,
|
|
3333
|
+
false,
|
|
3334
|
+
existingManifest.signer?.release,
|
|
3335
|
+
);
|
|
660
3336
|
} else if (!identity.ok && !options.restart) {
|
|
661
3337
|
throw new Error("Installed files are current, but Gateway runtime identity is stale.");
|
|
662
3338
|
}
|
|
@@ -683,6 +3359,7 @@ async function updateManagedRuntime(options) {
|
|
|
683
3359
|
const baseUrl = (
|
|
684
3360
|
process.env.FASED_HOSTED_ARTIFACT_BASE_URL || DEFAULT_RELEASE_BASE_URL
|
|
685
3361
|
).replace(/\/$/, "");
|
|
3362
|
+
const officialVersion = baseUrl === DEFAULT_RELEASE_BASE_URL ? targetVersion : null;
|
|
686
3363
|
const releaseUrl = `${baseUrl}/v${targetVersion}`;
|
|
687
3364
|
const updateCacheRoot = path.join(paths.stateDir, "install-cache");
|
|
688
3365
|
await fsp.mkdir(updateCacheRoot, { recursive: true });
|
|
@@ -693,14 +3370,43 @@ async function updateManagedRuntime(options) {
|
|
|
693
3370
|
let previousRoot = currentRoot;
|
|
694
3371
|
let previousManifest = existingManifest;
|
|
695
3372
|
try {
|
|
696
|
-
|
|
3373
|
+
let unifiedRelease = null;
|
|
3374
|
+
let unifiedReleasePath = null;
|
|
3375
|
+
if (existingManifest.profile === "hosting") {
|
|
3376
|
+
unifiedReleasePath = path.join(temporaryRoot, "fased-hosted-release-v2.json");
|
|
3377
|
+
await measureStage(timings, "unified release manifest", async () => {
|
|
3378
|
+
await downloadToFile(
|
|
3379
|
+
`${releaseUrl}/fased-hosted-release-v2.json`,
|
|
3380
|
+
unifiedReleasePath,
|
|
3381
|
+
options.timeoutMs,
|
|
3382
|
+
);
|
|
3383
|
+
if (officialVersion) {
|
|
3384
|
+
await verifyOfficialAsset(unifiedReleasePath, officialVersion, options.timeoutMs);
|
|
3385
|
+
}
|
|
3386
|
+
unifiedRelease = (
|
|
3387
|
+
await readHostedReleaseManifestV2(unifiedReleasePath, { version: targetVersion })
|
|
3388
|
+
).manifest;
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
const appArtifact = unifiedRelease?.application?.linux?.[arch]?.artifact;
|
|
3392
|
+
const assetName =
|
|
3393
|
+
appArtifact?.asset || `fased-hosted-app-linux-${arch}-v${targetVersion}.tar.gz`;
|
|
697
3394
|
const archive = await measureStage(timings, "application download and checksum", () =>
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
3395
|
+
appArtifact
|
|
3396
|
+
? downloadManifestBoundAsset({
|
|
3397
|
+
releaseUrl,
|
|
3398
|
+
artifact: appArtifact,
|
|
3399
|
+
destinationDir: temporaryRoot,
|
|
3400
|
+
timeoutMs: options.timeoutMs,
|
|
3401
|
+
officialVersion,
|
|
3402
|
+
})
|
|
3403
|
+
: downloadVerifiedAsset({
|
|
3404
|
+
releaseUrl,
|
|
3405
|
+
assetName,
|
|
3406
|
+
destinationDir: temporaryRoot,
|
|
3407
|
+
timeoutMs: options.timeoutMs,
|
|
3408
|
+
officialVersion,
|
|
3409
|
+
}),
|
|
704
3410
|
);
|
|
705
3411
|
await measureStage(timings, "application archive verification", () =>
|
|
706
3412
|
assertArchiveSafe(archive, "package"),
|
|
@@ -710,6 +3416,12 @@ async function updateManagedRuntime(options) {
|
|
|
710
3416
|
extractArchive(archive, extracted, options.timeoutMs),
|
|
711
3417
|
);
|
|
712
3418
|
const stagedRoot = path.join(extracted, "package");
|
|
3419
|
+
if (unifiedReleasePath) {
|
|
3420
|
+
await fsp.copyFile(
|
|
3421
|
+
unifiedReleasePath,
|
|
3422
|
+
path.join(stagedRoot, ".fased-hosted-release-v2.json"),
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
713
3425
|
const metadata = await readHostedRuntimeMetadata(stagedRoot);
|
|
714
3426
|
if (!metadata) {
|
|
715
3427
|
throw new Error("Hosted app metadata is missing or invalid.");
|
|
@@ -722,10 +3434,16 @@ async function updateManagedRuntime(options) {
|
|
|
722
3434
|
paths,
|
|
723
3435
|
temporaryRoot,
|
|
724
3436
|
timeoutMs: options.timeoutMs,
|
|
3437
|
+
officialVersion,
|
|
3438
|
+
manifestArtifact: unifiedRelease?.application?.linux?.[arch]?.dependencies,
|
|
725
3439
|
}),
|
|
726
3440
|
);
|
|
727
3441
|
await fsp.symlink(nodeModules, path.join(stagedRoot, "node_modules"), "dir");
|
|
728
3442
|
await assertManagedRuntime(stagedRoot, targetVersion);
|
|
3443
|
+
const hostedRelease = await readHostedReleaseBinding(stagedRoot, metadata, targetVersion);
|
|
3444
|
+
if (existingManifest.profile === "hosting" && !hostedRelease) {
|
|
3445
|
+
throw new Error("Maintained Hosting update omitted its attested unified release binding.");
|
|
3446
|
+
}
|
|
729
3447
|
await measureStage(timings, "staged runtime smoke", () =>
|
|
730
3448
|
smokeRuntime(stagedRoot, options.timeoutMs),
|
|
731
3449
|
);
|
|
@@ -752,46 +3470,193 @@ async function updateManagedRuntime(options) {
|
|
|
752
3470
|
await fsp.mkdir(paths.releasesDir, { recursive: true });
|
|
753
3471
|
await fsp.rename(stagedRoot, releaseRoot);
|
|
754
3472
|
}
|
|
3473
|
+
if (existingManifest.profile === "hosting") {
|
|
3474
|
+
await measureStage(timings, "release durability", async () => {
|
|
3475
|
+
await fsyncManagedReleaseTree(releaseRoot);
|
|
3476
|
+
await fsyncManagedPath(paths.releasesDir);
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
755
3479
|
|
|
756
3480
|
const nextManifest = buildManagedInstallManifest({
|
|
757
3481
|
paths,
|
|
758
3482
|
profile: existingManifest.profile,
|
|
759
3483
|
version: targetVersion,
|
|
760
3484
|
dependencyHash: metadata.dependencyHash,
|
|
3485
|
+
hostedRelease,
|
|
761
3486
|
previousVersion: currentVersion,
|
|
762
3487
|
});
|
|
763
|
-
|
|
764
|
-
if (previousRoot
|
|
765
|
-
|
|
3488
|
+
if (existingManifest.profile === "hosting") {
|
|
3489
|
+
if (!previousRoot) {
|
|
3490
|
+
throw new Error("Hosted transactional update requires an active previous runtime.");
|
|
766
3491
|
}
|
|
767
|
-
await
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
await
|
|
782
|
-
|
|
783
|
-
() => undefined,
|
|
3492
|
+
let journal = await writeHostedTransactionJournal(paths, {
|
|
3493
|
+
schemaVersion: HOSTED_TRANSACTION_SCHEMA_VERSION,
|
|
3494
|
+
transactionId: randomUUID(),
|
|
3495
|
+
targetVersion,
|
|
3496
|
+
previousVersion: currentVersion,
|
|
3497
|
+
targetRoot: releaseRoot,
|
|
3498
|
+
previousRoot,
|
|
3499
|
+
nextManifest,
|
|
3500
|
+
previousManifest,
|
|
3501
|
+
phase: "prepared",
|
|
3502
|
+
createdAt: new Date().toISOString(),
|
|
3503
|
+
});
|
|
3504
|
+
const operations = hostedTransactionOperations(paths, options.timeoutMs);
|
|
3505
|
+
try {
|
|
3506
|
+
await measureStage(timings, "root signer release preparation", () =>
|
|
3507
|
+
operations.signerRequest("prepareRelease", journal),
|
|
784
3508
|
);
|
|
3509
|
+
} catch (error) {
|
|
3510
|
+
if (error?.hostUpdaterAmbiguous !== true) {
|
|
3511
|
+
await operations.removeJournal();
|
|
3512
|
+
} else {
|
|
3513
|
+
try {
|
|
3514
|
+
await operations.signerRequest("rollbackRelease", journal);
|
|
3515
|
+
await operations.removeJournal();
|
|
3516
|
+
} catch {
|
|
3517
|
+
// Preserve the transaction ID for deterministic recovery if prepare was ambiguous.
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
throw error;
|
|
785
3521
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
3522
|
+
try {
|
|
3523
|
+
await measureStage(timings, "coordinated activation and health", async () => {
|
|
3524
|
+
const result = await coordinateHostedReleaseTransaction(journal, operations);
|
|
3525
|
+
journal = result.journal;
|
|
3526
|
+
});
|
|
3527
|
+
activated = true;
|
|
3528
|
+
} catch (error) {
|
|
3529
|
+
if (error?.code === "HOSTED_COMMIT_PENDING") {
|
|
3530
|
+
activated = true;
|
|
3531
|
+
}
|
|
3532
|
+
throw error;
|
|
3533
|
+
}
|
|
3534
|
+
} else {
|
|
3535
|
+
const signerPaths = resolveLocalSignerPaths({ stateDir: paths.stateDir });
|
|
3536
|
+
const pairSigner =
|
|
3537
|
+
Boolean(previousRoot) && (await localSignerIsInstalledOrConfigured(signerPaths));
|
|
3538
|
+
if (pairSigner) {
|
|
3539
|
+
let pairedJournal = await writeLocalPairedUpdateJournal(paths, {
|
|
3540
|
+
schemaVersion: 1,
|
|
3541
|
+
transactionId: randomUUID(),
|
|
3542
|
+
targetVersion,
|
|
3543
|
+
previousVersion: currentVersion,
|
|
3544
|
+
targetRoot: releaseRoot,
|
|
3545
|
+
previousRoot,
|
|
3546
|
+
nextManifest,
|
|
3547
|
+
previousManifest,
|
|
3548
|
+
phase: "prepared",
|
|
3549
|
+
createdAt: new Date().toISOString(),
|
|
3550
|
+
});
|
|
3551
|
+
try {
|
|
3552
|
+
const signer = await measureStage(timings, "Local signer prepare and preflight", () =>
|
|
3553
|
+
runLocalSignerTransaction(
|
|
3554
|
+
{
|
|
3555
|
+
action: "install",
|
|
3556
|
+
targetVersion,
|
|
3557
|
+
timeoutMs: options.timeoutMs,
|
|
3558
|
+
deferCommit: true,
|
|
3559
|
+
},
|
|
3560
|
+
{ stateDir: paths.stateDir },
|
|
3561
|
+
),
|
|
3562
|
+
);
|
|
3563
|
+
pairedJournal.nextManifest = {
|
|
3564
|
+
...pairedJournal.nextManifest,
|
|
3565
|
+
signer: { release: signer.identity },
|
|
3566
|
+
};
|
|
3567
|
+
pairedJournal = await writeLocalPairedUpdateJournal(
|
|
3568
|
+
paths,
|
|
3569
|
+
pairedJournal,
|
|
3570
|
+
"signer-active",
|
|
3571
|
+
);
|
|
3572
|
+
await measureStage(timings, "paired runtime activation", async () => {
|
|
3573
|
+
if (path.resolve(previousRoot) !== path.resolve(releaseRoot)) {
|
|
3574
|
+
await atomicSymlink(previousRoot, paths.previousLink);
|
|
3575
|
+
}
|
|
3576
|
+
await atomicSymlink(releaseRoot, paths.currentLink);
|
|
3577
|
+
await replaceCompatibilityLink(paths);
|
|
3578
|
+
await atomicWriteJson(paths.manifestPath, pairedJournal.nextManifest, 0o600);
|
|
3579
|
+
await syncManagedActivation(paths);
|
|
3580
|
+
});
|
|
3581
|
+
activated = true;
|
|
3582
|
+
pairedJournal = await writeLocalPairedUpdateJournal(paths, pairedJournal, "app-active");
|
|
3583
|
+
await measureStage(timings, "paired Gateway and signer health", async () => {
|
|
3584
|
+
await refreshGateway(releaseRoot, pairedJournal.nextManifest, options.timeoutMs);
|
|
3585
|
+
await probeLocalSignerHealth(
|
|
3586
|
+
signerPaths.socketPath,
|
|
3587
|
+
pairedJournal.nextManifest.signer.release,
|
|
3588
|
+
Math.min(options.timeoutMs, 15_000),
|
|
3589
|
+
);
|
|
3590
|
+
});
|
|
3591
|
+
pairedJournal = await writeLocalPairedUpdateJournal(
|
|
3592
|
+
paths,
|
|
3593
|
+
pairedJournal,
|
|
3594
|
+
"gateway-verified",
|
|
3595
|
+
);
|
|
3596
|
+
const committed = await runLocalSignerTransaction(
|
|
3597
|
+
{ action: "commit" },
|
|
3598
|
+
{ stateDir: paths.stateDir },
|
|
3599
|
+
);
|
|
3600
|
+
if (
|
|
3601
|
+
!signerIdentitiesEqual(committed.identity, pairedJournal.nextManifest.signer.release)
|
|
3602
|
+
) {
|
|
3603
|
+
throw new Error("Committed signer identity changed after paired health verification");
|
|
3604
|
+
}
|
|
3605
|
+
await measureStage(timings, "paired updater commit cleanup", async () => {
|
|
3606
|
+
await atomicWriteJson(paths.manifestPath, pairedJournal.nextManifest, 0o600);
|
|
3607
|
+
await syncManagedActivation(paths);
|
|
3608
|
+
await updateStableComponents(paths, releaseRoot, true);
|
|
3609
|
+
await cleanupReleases(paths, [releaseRoot, previousRoot]);
|
|
3610
|
+
await removeLocalPairedUpdateJournal(paths);
|
|
3611
|
+
});
|
|
3612
|
+
} catch (error) {
|
|
3613
|
+
if (pairedJournal.phase === "gateway-verified") {
|
|
3614
|
+
const pending = new Error(
|
|
3615
|
+
`The Local Gateway and signer passed exact health, but commit cleanup is pending: ${error.message}. Re-run fased update to finish forward recovery.`,
|
|
3616
|
+
{ cause: error },
|
|
3617
|
+
);
|
|
3618
|
+
pending.code = "LOCAL_PAIRED_COMMIT_PENDING";
|
|
3619
|
+
throw pending;
|
|
3620
|
+
}
|
|
3621
|
+
activated = false;
|
|
3622
|
+
return await rollbackLocalPairedUpdate(paths, pairedJournal, options.timeoutMs, error);
|
|
3623
|
+
}
|
|
3624
|
+
} else {
|
|
3625
|
+
await measureStage(timings, "runtime activation", async () => {
|
|
3626
|
+
if (previousRoot && path.resolve(previousRoot) !== path.resolve(releaseRoot)) {
|
|
3627
|
+
await atomicSymlink(previousRoot, paths.previousLink);
|
|
3628
|
+
}
|
|
3629
|
+
await atomicSymlink(releaseRoot, paths.currentLink);
|
|
3630
|
+
await replaceCompatibilityLink(paths);
|
|
3631
|
+
await atomicWriteJson(paths.manifestPath, nextManifest, 0o600);
|
|
3632
|
+
});
|
|
3633
|
+
activated = true;
|
|
790
3634
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
3635
|
+
try {
|
|
3636
|
+
await measureStage(timings, "Gateway refresh and health", () =>
|
|
3637
|
+
refreshGateway(releaseRoot, nextManifest, options.timeoutMs),
|
|
3638
|
+
);
|
|
3639
|
+
} catch (error) {
|
|
3640
|
+
if (previousRoot) {
|
|
3641
|
+
await atomicSymlink(previousRoot, paths.currentLink);
|
|
3642
|
+
await replaceCompatibilityLink(paths);
|
|
3643
|
+
await atomicWriteJson(paths.manifestPath, previousManifest, 0o600);
|
|
3644
|
+
await refreshGateway(previousRoot, previousManifest, options.timeoutMs).catch(
|
|
3645
|
+
() => undefined,
|
|
3646
|
+
);
|
|
3647
|
+
}
|
|
3648
|
+
throw new Error(
|
|
3649
|
+
`Update rolled back after health verification failed: ${error.message}`,
|
|
3650
|
+
{ cause: error },
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
await measureStage(timings, "updater and rollback cleanup", async () => {
|
|
3655
|
+
await updateStableComponents(paths, releaseRoot);
|
|
3656
|
+
await cleanupReleases(paths, [releaseRoot, previousRoot]);
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
795
3660
|
timings.push({ name: "total", durationMs: Date.now() - commandStartedAt });
|
|
796
3661
|
if (options.json) {
|
|
797
3662
|
console.log(
|
|
@@ -837,6 +3702,19 @@ async function updateManagedRuntime(options) {
|
|
|
837
3702
|
}
|
|
838
3703
|
|
|
839
3704
|
export async function run(argv = process.argv.slice(2)) {
|
|
3705
|
+
if (argv[0] === "local-signer") {
|
|
3706
|
+
const result = await runLocalSignerTransaction(parseLocalSignerTransactionArgs(argv));
|
|
3707
|
+
process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
if (argv[0] === "hosted-transaction") {
|
|
3711
|
+
const { action, timeoutMs, targetServiceAlreadyRestarted } = parseHostedTransactionArgs(argv);
|
|
3712
|
+
const result = await runHostedTransactionControl(action, timeoutMs, {
|
|
3713
|
+
targetServiceAlreadyRestarted,
|
|
3714
|
+
});
|
|
3715
|
+
process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
840
3718
|
const parsed = parseArgs(argv);
|
|
841
3719
|
const paths = resolveManagedRuntimePaths();
|
|
842
3720
|
if (parsed.delegate) {
|
|
@@ -875,5 +3753,36 @@ export const __testing = {
|
|
|
875
3753
|
resolveArchitecture,
|
|
876
3754
|
archiveEntryIsSafe,
|
|
877
3755
|
assertArchiveSafe,
|
|
3756
|
+
authorizePreactivatedHostedGateway,
|
|
3757
|
+
beginPreactivatedHostedTransaction,
|
|
3758
|
+
coordinateHostedReleaseTransaction,
|
|
3759
|
+
hostedUpdaterError,
|
|
878
3760
|
probeGatewayIdentity,
|
|
3761
|
+
probeHostedSignerCompatibility,
|
|
3762
|
+
readHostedTransactionJournal,
|
|
3763
|
+
recoverHostedReleaseTransaction,
|
|
3764
|
+
requestHostedSignerTransaction,
|
|
3765
|
+
requestHostedSignerTransactionWithRetry,
|
|
3766
|
+
rollbackHostedReleaseTransaction,
|
|
3767
|
+
restartHostedGateway,
|
|
3768
|
+
activateLocalSignerTransaction,
|
|
3769
|
+
commitLocalSignerTransaction,
|
|
3770
|
+
createOwnerOnlySnapshot,
|
|
3771
|
+
downloadVerifiedLocalSignerRelease,
|
|
3772
|
+
loadSignerEnvironment,
|
|
3773
|
+
parseLocalSignerTransactionArgs,
|
|
3774
|
+
parseHostedTransactionArgs,
|
|
3775
|
+
parseSignerReleaseManifest,
|
|
3776
|
+
parseSignerVersionOutput,
|
|
3777
|
+
prepareLocalSignerTransaction,
|
|
3778
|
+
probeLocalSignerHealth,
|
|
3779
|
+
readLocalSignerJournal,
|
|
3780
|
+
recoverLocalSignerTransaction,
|
|
3781
|
+
resolveLocalSignerAsset,
|
|
3782
|
+
resolveLocalSignerPaths,
|
|
3783
|
+
restoreOwnerOnlySnapshot,
|
|
3784
|
+
rollbackLocalSignerTransaction,
|
|
3785
|
+
runLocalSignerTransaction,
|
|
3786
|
+
runHostedTransactionControl,
|
|
3787
|
+
validateHostedTransactionJournal,
|
|
879
3788
|
};
|