@fased/fased 0.1.62 → 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.
Files changed (341) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +38 -30
  3. package/config/dependency-ownership.json +1 -1
  4. package/config/signer-policies/README.md +49 -0
  5. package/config/signer-policies/agent.json.template +32 -0
  6. package/config/signer-policies/mining.json.template +69 -0
  7. package/config/signer-policies/network.json.template +5 -0
  8. package/config/signer-policies/vault.json.template +70 -0
  9. package/config/signer-protocol-v2.json +76 -0
  10. package/config/typescript-noemit-baseline.json +680 -0
  11. package/dist/agents/auth-profiles/store.js +267 -31
  12. package/dist/agents/bash-tools.exec.js +10 -4
  13. package/dist/agents/current-model-catalog.js +3 -3
  14. package/dist/agents/memory-search.js +7 -1
  15. package/dist/agents/model-catalog-access.js +1 -2
  16. package/dist/agents/model-catalog-snapshot.js +1 -1
  17. package/dist/agents/model-catalog-source.js +1 -1
  18. package/dist/agents/model-metadata.js +1 -1
  19. package/dist/agents/model-selection.js +2 -28
  20. package/dist/agents/models-config.providers.js +6 -6
  21. package/dist/agents/pi-embedded-runner/compact.js +15 -7
  22. package/dist/agents/pi-embedded-runner/model.js +1 -1
  23. package/dist/agents/pi-embedded-runner/run/attempt.js +24 -13
  24. package/dist/agents/pi-embedded-runner/run.js +2 -0
  25. package/dist/agents/pi-embedded-runner/tool-split.js +40 -2
  26. package/dist/agents/pi-tool-definition-adapter.js +16 -1
  27. package/dist/agents/pi-tools.js +2 -1
  28. package/dist/agents/sandbox/config.js +1 -1
  29. package/dist/agents/sandbox/context.js +27 -2
  30. package/dist/agents/skills/env-overrides.js +38 -28
  31. package/dist/agents/skills/trust.js +93 -0
  32. package/dist/agents/skills/workspace.js +128 -0
  33. package/dist/agents/skills-clawhub.js +36 -11
  34. package/dist/agents/skills-marketplace-scan.js +49 -0
  35. package/dist/agents/tools/marketplace-offer-draft-tool.js +1 -1
  36. package/dist/agents/tools/mining-tool.js +1 -1
  37. package/dist/agents/tools/session-status-tool.js +1 -1
  38. package/dist/agents/tools/wallet-action-tool.js +172 -36
  39. package/dist/agents/tools/wallet-skill-policy.js +65 -23
  40. package/dist/agents/tools/wallet-tool.js +7 -11
  41. package/dist/auto-reply/reply/directive-handling.fast-lane.js +2 -1
  42. package/dist/auto-reply/reply/directive-handling.impl.js +2 -1
  43. package/dist/auto-reply/reply/directive-handling.model.js +2 -1
  44. package/dist/auto-reply/reply/directive-handling.persist.js +2 -2
  45. package/dist/auto-reply/reply/get-reply-directives-apply.js +2 -0
  46. package/dist/auto-reply/reply/get-reply-inline-actions.js +25 -1
  47. package/dist/auto-reply/reply/model-selection.js +7 -4
  48. package/dist/auto-reply/reply/session-reset-model.js +4 -4
  49. package/dist/brand.js +1 -1
  50. package/dist/build-info.json +3 -3
  51. package/dist/canvas-host/a2ui/.bundle.hash +1 -1
  52. package/dist/channel-web.js +2 -2
  53. package/dist/cli/gateway-cli/run.js +8 -0
  54. package/dist/cli/program/command-registry.js +1 -1
  55. package/dist/cli/program/register.onboard.js +2 -3
  56. package/dist/cli/program/register.wallet.js +16 -123
  57. package/dist/cli/providers-cli.js +1 -1
  58. package/dist/cli/sat-cli.js +129 -2
  59. package/dist/cli/update-cli/update-command.js +260 -11
  60. package/dist/commands/auth-choice.apply.api-providers.js +3 -3
  61. package/dist/commands/doctor-config-flow.js +1 -1
  62. package/dist/commands/doctor-legacy-config.js +2 -13
  63. package/dist/commands/model-picker.js +6 -6
  64. package/dist/commands/models/auth.js +1 -1
  65. package/dist/commands/onboard-auth.config-gateways.js +1 -1
  66. package/dist/commands/onboard-auth.credentials.js +32 -8
  67. package/dist/commands/onboard-non-interactive/local/wallet-config.js +28 -27
  68. package/dist/commands/onboard-non-interactive/local.js +5 -1
  69. package/dist/commands/wallet.js +568 -1911
  70. package/dist/config/config.js +2 -2
  71. package/dist/config/io.js +64 -2
  72. package/dist/config/schema.help.js +8 -7
  73. package/dist/config/schema.labels.js +5 -4
  74. package/dist/config/zod-schema.agent-runtime.js +1 -0
  75. package/dist/config/zod-schema.js +5 -0
  76. package/dist/control-ui/assets/{app-DLil9VO1.js → app-bR-fO5ty.js} +1511 -1555
  77. package/dist/control-ui/assets/{config-CU5Zi8Dh.js → config-LeImukCW.js} +10 -10
  78. package/dist/control-ui/assets/{federation-D10Q4eP_.js → federation-DgA9e-Pn.js} +364 -422
  79. package/dist/control-ui/assets/{index-DdE_NIXo.js → index-ckCxHwjy.js} +3 -3
  80. package/dist/control-ui/assets/{mining-BdhMn18r.js → mining-BgHFh0ei.js} +134 -114
  81. package/dist/control-ui/assets/mining-commit-DFpB_4JN.js +2 -0
  82. package/dist/control-ui/assets/{open-external-url-DwSiwMPl.js → open-external-url-ulF1B-sA.js} +3 -3
  83. package/dist/control-ui/assets/{providers-QMn4w2_f.js → providers-CIx9fxc6.js} +2 -2
  84. package/dist/control-ui/assets/{wallet-C2g294QT.js → wallet-DvStR_AT.js} +259 -377
  85. package/dist/control-ui/index.html +1 -1
  86. package/dist/control-ui/version.json +1 -1
  87. package/dist/daemon/systemd-system.js +56 -50
  88. package/dist/extensions/sat-mining/src/protocol-contract.js +43 -3
  89. package/dist/extensions/sat-mining/src/rpc-read.js +235 -67
  90. package/dist/extensions/sat-mining/src/sat-bond-layout.js +5 -2
  91. package/dist/extensions/sat-mining/src/signer-codec-manifest.js +406 -0
  92. package/dist/extensions/sat-mining/src/solana-submit.js +1038 -397
  93. package/dist/extensions/sat-mining/src/submission-ledger.js +349 -0
  94. package/dist/federation/auto-connect.js +98 -8
  95. package/dist/federation/marketplace-capability-adapter.js +33 -7
  96. package/dist/federation/marketplace-delivery-outbox.js +196 -0
  97. package/dist/federation/marketplace-delivery.js +351 -215
  98. package/dist/federation/marketplace-direct-payment.js +272 -141
  99. package/dist/federation/marketplace-escrow.js +607 -249
  100. package/dist/federation/marketplace-settlement-ledger.js +352 -0
  101. package/dist/federation/offers.js +9 -7
  102. package/dist/federation/peer-auth-v2.js +529 -0
  103. package/dist/gateway/a2a-http.js +425 -96
  104. package/dist/gateway/a2a-settlement.js +259 -153
  105. package/dist/gateway/a2a-task-store.js +524 -0
  106. package/dist/gateway/auth.js +38 -2
  107. package/dist/gateway/config-reload.js +46 -33
  108. package/dist/gateway/durable-capacity.js +31 -0
  109. package/dist/gateway/federation-http.js +540 -135
  110. package/dist/gateway/federation-marketplace-run-store.js +397 -0
  111. package/dist/gateway/federation-marketplace.js +535 -230
  112. package/dist/gateway/mutating-admin-rpc-rate-limit.js +5 -0
  113. package/dist/gateway/security-path.js +1 -1
  114. package/dist/gateway/server-http.js +1084 -1205
  115. package/dist/gateway/server-methods/models.js +1 -1
  116. package/dist/gateway/server-methods/secrets.js +40 -0
  117. package/dist/gateway/server-methods.js +1 -2
  118. package/dist/gateway/server-reload-handlers.js +196 -66
  119. package/dist/gateway/server-runtime-config.js +1 -1
  120. package/dist/gateway/server-secrets-runtime.js +288 -0
  121. package/dist/gateway/server.impl.js +73 -42
  122. package/dist/gateway/sessions-patch.js +3 -5
  123. package/dist/index.js +4 -2
  124. package/dist/infra/fasedhub.js +64 -1
  125. package/dist/infra/local-source-paired-update.js +538 -0
  126. package/dist/infra/restart.js +41 -10
  127. package/dist/infra/update-runner.js +96 -0
  128. package/dist/light-update-status.js +11 -11
  129. package/dist/memory/backend-config.js +2 -1
  130. package/dist/memory/session-files.js +16 -2
  131. package/dist/plugin-sdk/{accounts-DxblobYM.js → accounts-Brn3-uWN.js} +1 -1
  132. package/dist/plugin-sdk/{actions-D4UNimWa.js → actions-DRu135h0.js} +2 -2
  133. package/dist/plugin-sdk/{active-listener-CFuy106s.js → active-listener-B_NMtgPL.js} +1 -1
  134. package/dist/plugin-sdk/{audio-preflight-Bwi2Oq03.js → audio-preflight-BYxAt775.js} +27 -27
  135. package/dist/plugin-sdk/{audit-Cs0K7LBp.js → audit-C5d2z5U1.js} +1 -1
  136. package/dist/plugin-sdk/{channel-access-D5aHoTY8.js → channel-access-CcFRdRPO.js} +1 -1
  137. package/dist/plugin-sdk/{channel-access-configure-Dvch9mMQ.js → channel-access-configure-DaI5wevh.js} +1 -1
  138. package/dist/plugin-sdk/channel-plugin-common.d.ts +3 -3
  139. package/dist/plugin-sdk/channel-plugin-common.js +4 -4
  140. package/dist/plugin-sdk/{chunk-B0FUGc5A.js → chunk-CyoyqpbG.js} +1 -1
  141. package/dist/plugin-sdk/{chunk-items-DzT4L4Ty.js → chunk-items-DIRHG2gJ.js} +1 -1
  142. package/dist/plugin-sdk/command-status.d.ts +2 -2
  143. package/dist/plugin-sdk/command-status.js +27 -27
  144. package/dist/plugin-sdk/{commands-registry-DmDRhRAU.js → commands-registry-BfqdEWdF.js} +2 -2
  145. package/dist/plugin-sdk/{common-CfUOowOp.js → common-HCcXU-NR.js} +1 -1
  146. package/dist/plugin-sdk/{config-mxULv0fQ.js → config-DlWVeLFB.js} +7113 -6877
  147. package/dist/plugin-sdk/{config-schema-BrkmtFxB.js → config-schema-BOT7kHb6.js} +6779 -3696
  148. package/dist/plugin-sdk/device-pair.d.ts +1 -1
  149. package/dist/plugin-sdk/discord.d.ts +6 -6
  150. package/dist/plugin-sdk/discord.js +19 -19
  151. package/dist/plugin-sdk/file-lock.js +35 -4
  152. package/dist/plugin-sdk/{filesystem-manager-ZoHU8oA7.js → filesystem-manager-DoI8IBNF.js} +1 -1
  153. package/dist/plugin-sdk/{group-mentions-BHRff6j8.d.ts → group-mentions-q3hUTCt7.d.ts} +1 -1
  154. package/dist/plugin-sdk/{handle-action-DbVCF9HZ.js → handle-action-hvwapMKg.js} +13 -13
  155. package/dist/plugin-sdk/{helpers-BXbdgs5H.js → helpers-DMBU4oTo.js} +1 -1
  156. package/dist/plugin-sdk/{image-ops-D7jPxaph.js → image-ops-DQi0n_Tt.js} +1 -1
  157. package/dist/plugin-sdk/index.d.ts +21 -39
  158. package/dist/plugin-sdk/index.js +29 -29
  159. package/dist/plugin-sdk/{login-BRxww6nv.js → login-Ca583Okc.js} +3 -3
  160. package/dist/plugin-sdk/{login-qr-DJXsk9XH.js → login-qr-CGciBSNP.js} +3 -3
  161. package/dist/plugin-sdk/{manager-Y2S_5IlI.js → manager-BegWxNYX.js} +4 -4
  162. package/dist/plugin-sdk/{markdown-tables-DHLFQqaF.js → markdown-tables-DCOXEUfZ.js} +1 -1
  163. package/dist/plugin-sdk/{media-Czo4carg.js → media-CAdQF5TG.js} +47 -47
  164. package/dist/plugin-sdk/{memory-search-DTjIMw8J.js → memory-search-CrFPOeTH.js} +4 -1
  165. package/dist/plugin-sdk/{monitor-M_ta2lMc.js → monitor-BCFDaqJ-.js} +31 -31
  166. package/dist/plugin-sdk/{monitor-wYVncM9w.js → monitor-BgbjRiPM.js} +32 -32
  167. package/dist/plugin-sdk/{openresponses-http-B2GmeSt4.js → openresponses-http-DFf87SMM.js} +27 -27
  168. package/dist/plugin-sdk/{optional-runtime-dependency-DdMbxS24.js → optional-runtime-dependency-0dznm3ZO.js} +1 -1
  169. package/dist/plugin-sdk/{outbound-Db8mRN6P.js → outbound-BgVDdHxx.js} +6 -6
  170. package/dist/plugin-sdk/{outbound-attachment-BqFjcypb.js → outbound-attachment-QqTnIhZL.js} +2 -2
  171. package/dist/plugin-sdk/{pairing-message-B3bHYGaw.d.ts → pairing-message-CS-9BHug.d.ts} +2 -2
  172. package/dist/plugin-sdk/{pairing-message-B3GC_7CD.js → pairing-message-D0CoLHRJ.js} +1 -1
  173. package/dist/plugin-sdk/{pairing-store-CQikgIbw.js → pairing-store-BChahgI2.js} +2 -2
  174. package/dist/plugin-sdk/{pi-model-discovery-CY3m5zJP.js → pi-model-discovery-BX_JLcEJ.js} +1 -1
  175. package/dist/plugin-sdk/{pi-model-discovery-runtime-DSh1T2pn.js → pi-model-discovery-runtime-DxTMz_XF.js} +2 -2
  176. package/dist/plugin-sdk/provider-web-search-config-contract.d.ts +2 -2
  177. package/dist/plugin-sdk/{pw-ai-AFUqn-tQ.js → pw-ai-natjh3rm.js} +3 -3
  178. package/dist/plugin-sdk/{qmd-manager-DSyx77KY.js → qmd-manager-CgRaTxUq.js} +1 -1
  179. package/dist/plugin-sdk/{query-expansion-CAI2kKLQ.js → query-expansion-BG3FgzF7.js} +14 -3
  180. package/dist/plugin-sdk/{replies-Fzh9PhXl.js → replies-ChYo5ZQM.js} +2 -2
  181. package/dist/plugin-sdk/{resolve-outbound-target-KpF3co6T.js → resolve-outbound-target-D-KGgHJZ.js} +1 -1
  182. package/dist/plugin-sdk/{resolve-route-CDl74O19.js → resolve-route-C8tvQm8Z.js} +1 -1
  183. package/dist/plugin-sdk/{retry-policy-D4uiqUOG.js → retry-policy-C6SjKjQA.js} +1 -1
  184. package/dist/plugin-sdk/{runtime-Bb6KRnCC.js → runtime-2B9_WKoI.js} +8 -3
  185. package/dist/plugin-sdk/{runtime-dependency-DLDmB94f.js → runtime-dependency-DczkvPxn.js} +2 -2
  186. package/dist/plugin-sdk/sat-runtime.d.ts +549 -309
  187. package/dist/plugin-sdk/sat-runtime.js +29 -29
  188. package/dist/plugin-sdk/{send-C4k4JuJN.js → send-B6KkGKZD.js} +6 -6
  189. package/dist/plugin-sdk/{send-D-uDKGIq.js → send-CFzGKJXo.js} +6 -6
  190. package/dist/plugin-sdk/{send-Du8ykP1k.js → send-DCO441U7.js} +8 -8
  191. package/dist/plugin-sdk/{send-5nFktR1n.js → send-gDoGA-7e.js} +5 -5
  192. package/dist/plugin-sdk/{send-Bfzf11VY.js → send-q9HXrSwK.js} +7 -7
  193. package/dist/plugin-sdk/{session-CS-Td9EM.js → session-DHD4ZKyG.js} +2 -2
  194. package/dist/plugin-sdk/{skill-commands-CFAlXWHk.js → skill-commands-FKEPLS7T.js} +201 -26
  195. package/dist/plugin-sdk/{slack-CxE1mfzj.js → slack-BUTt8cxK.js} +39 -39
  196. package/dist/plugin-sdk/{slack-actions-BcMkNv1Z.js → slack-actions-DhbMcp8M.js} +11 -11
  197. package/dist/plugin-sdk/slack.d.ts +6 -6
  198. package/dist/plugin-sdk/slack.js +9 -9
  199. package/dist/plugin-sdk/{sticker-cache-CGzGWu00.js → sticker-cache-D0wHVqjt.js} +3 -3
  200. package/dist/plugin-sdk/{tables-bFXyRpkx.js → tables-B3jr0Pvq.js} +1 -1
  201. package/dist/plugin-sdk/{targets-DH7a9lKB.js → targets-CtSbTxYX.js} +1 -1
  202. package/dist/plugin-sdk/{telegram-actions-DaEcYQ8r.js → telegram-actions-CC_qN4v0.js} +30 -30
  203. package/dist/plugin-sdk/telegram.d.ts +6 -6
  204. package/dist/plugin-sdk/telegram.js +8 -8
  205. package/dist/plugin-sdk/{thread-bindings.lifecycle-CgOvvdw8.js → thread-bindings.lifecycle-QbkNl-m6.js} +3 -3
  206. package/dist/plugin-sdk/{thread-bindings.state-CQpk44Hq.js → thread-bindings.state-D9hg_M1c.js} +2 -2
  207. package/dist/plugin-sdk/tmp-fased-dir-xOTGlzBE.d.ts +486 -0
  208. package/dist/plugin-sdk/{types-vcJrC_uv.d.ts → types-C3avNytw.d.ts} +3 -4
  209. package/dist/plugin-sdk/{types-DyhXnYUr.d.ts → types-D0kDG8l0.d.ts} +3 -2
  210. package/dist/plugin-sdk/{types-DFAW-n1J.d.ts → types-DWfbSAaM.d.ts} +2 -2
  211. package/dist/plugin-sdk/{types.plugin-_fbu40Re.d.ts → types.plugin-BcSBq42x.d.ts} +8 -3
  212. package/dist/plugin-sdk/{web-CInY-lCv.js → web-BXsJ1vqe.js} +32 -32
  213. package/dist/plugin-sdk/{whatsapp-actions-BtSc2CBv.js → whatsapp-actions-Bj_rOncr.js} +13 -13
  214. package/dist/plugin-sdk/{whatsapp-heartbeat-Bg9TuYzl.js → whatsapp-heartbeat-B1WHkJIg.js} +2 -2
  215. package/dist/plugin-sdk/whatsapp.d.ts +13 -13
  216. package/dist/plugin-sdk/whatsapp.js +12 -12
  217. package/dist/plugin-sdk/{wsl-C4DGFkOd.js → wsl-J-HscWxS.js} +3 -3
  218. package/dist/plugin-sdk/{zod-schema.providers-core-BEbNizPe.d.ts → zod-schema.providers-core-CIzCjXH9.d.ts} +61 -61
  219. package/dist/providers/refresh.js +1 -1
  220. package/dist/providers/registry.js +1 -1
  221. package/dist/secrets/apply.js +1 -1
  222. package/dist/secrets/runtime.js +1 -1
  223. package/dist/wallet/external-submission-ledger.js +376 -0
  224. package/dist/wallet/legacy-embedded-keystore.js +40 -0
  225. package/dist/wallet/local-socket-signer-archive.js +30 -0
  226. package/dist/wallet/local-socket-signer-lifecycle.js +97 -0
  227. package/dist/wallet/local-socket-signer-policy.js +166 -0
  228. package/dist/wallet/local-socket-signer-protocol.js +561 -118
  229. package/dist/wallet/native-signer-wallet-id.js +25 -0
  230. package/dist/wallet/providers/alchemy-adapter.js +3 -0
  231. package/dist/wallet/providers/local-socket-signer-adapter.js +380 -105
  232. package/dist/wallet/providers/turnkey-adapter.js +292 -34
  233. package/dist/wallet/providers/wallet-standard-adapter.js +100 -0
  234. package/dist/wallet/signer-network-admin.js +184 -0
  235. package/dist/wallet/signer-protocol-v2.generated.js +90 -0
  236. package/dist/wallet/solana-bond-signing.js +157 -69
  237. package/dist/wallet/solana-reviewed-transaction.js +375 -0
  238. package/dist/wallet/solana-swap.js +520 -64
  239. package/dist/wallet/solana-transaction-inspection.js +106 -4
  240. package/dist/wallet/solana-trigger.js +658 -320
  241. package/dist/wallet/turnkey-reviewed-state.js +370 -0
  242. package/dist/wallet/wallet-approval-auth.js +17 -28
  243. package/dist/wallet/wallet-atomic-state.js +53 -0
  244. package/dist/wallet/wallet-canary.js +0 -1
  245. package/dist/wallet/wallet-inbound-events.js +16 -11
  246. package/dist/wallet/wallet-policy-simulation.js +1 -1
  247. package/dist/wallet/wallet-policy.js +90 -36
  248. package/dist/wallet/wallet-provider-capabilities.js +28 -4
  249. package/dist/wallet/wallet-provider-registry.js +57 -44
  250. package/dist/wallet/wallet-provider-resolver.js +62 -63
  251. package/dist/wallet/wallet-runtime-config.js +28 -41
  252. package/dist/wallet/wallet-secrets-store.js +22 -0
  253. package/dist/wallet/wallet-send-approvals.js +1891 -589
  254. package/dist/wallet/wallet-send-execution-ledger.js +292 -0
  255. package/dist/wallet/wallet-settlement-links.js +14 -13
  256. package/dist/wallet/wallet-standard-review.js +353 -0
  257. package/dist/wallet/wallet-status.js +0 -9
  258. package/dist/wallet/wallet-task-ledger.js +16 -2
  259. package/dist/web/auto-reply/heartbeat-runner.js +1 -1
  260. package/dist/web/auto-reply/monitor.js +2 -2
  261. package/dist/wizard/host-security-capability.js +14 -23
  262. package/dist/wizard/onboarding.finalize.js +63 -184
  263. package/dist/wizard/onboarding.host-security.js +117 -1367
  264. package/dist/wizard/onboarding.js +172 -217
  265. package/dist/wizard/onboarding.wallet.js +232 -785
  266. package/extensions/acpx/package.json +1 -1
  267. package/extensions/bluebubbles/package.json +1 -1
  268. package/extensions/copilot-proxy/package.json +1 -1
  269. package/extensions/diagnostics-otel/package.json +1 -1
  270. package/extensions/fased-federation/package.json +1 -1
  271. package/extensions/google-gemini-cli-auth/package.json +1 -1
  272. package/extensions/imessage/package.json +1 -1
  273. package/extensions/irc/package.json +1 -1
  274. package/extensions/line/package.json +1 -1
  275. package/extensions/llm-task/package.json +1 -1
  276. package/extensions/lobster/package.json +1 -1
  277. package/extensions/matrix/CHANGELOG.md +12 -0
  278. package/extensions/matrix/package.json +1 -1
  279. package/extensions/mattermost/package.json +1 -1
  280. package/extensions/memory-core/package.json +2 -2
  281. package/extensions/memory-lancedb/package.json +1 -1
  282. package/extensions/minimax-portal-auth/package.json +1 -1
  283. package/extensions/msteams/CHANGELOG.md +12 -0
  284. package/extensions/msteams/package.json +1 -1
  285. package/extensions/nextcloud-talk/package.json +1 -1
  286. package/extensions/nostr/CHANGELOG.md +12 -0
  287. package/extensions/nostr/package.json +1 -1
  288. package/extensions/open-prose/package.json +1 -1
  289. package/extensions/sat-mining/index.ts +498 -153
  290. package/extensions/sat-mining/package.json +1 -1
  291. package/extensions/sat-mining/signer-codec-schema.v1.json +374 -0
  292. package/extensions/sat-mining/src/audit-store.ts +50 -5
  293. package/extensions/sat-mining/src/claim-service.ts +85 -14
  294. package/extensions/sat-mining/src/config.ts +9 -0
  295. package/extensions/sat-mining/src/cycle-progress.ts +16 -8
  296. package/extensions/sat-mining/src/epoch-service.ts +9 -5
  297. package/extensions/sat-mining/src/gateway-runner.ts +21 -1
  298. package/extensions/sat-mining/src/protocol-contract.ts +45 -1
  299. package/extensions/sat-mining/src/recovery-service.ts +312 -0
  300. package/extensions/sat-mining/src/round-watcher.ts +391 -81
  301. package/extensions/sat-mining/src/rpc-read.ts +330 -89
  302. package/extensions/sat-mining/src/runtime.ts +14 -0
  303. package/extensions/sat-mining/src/sat-bond-layout.ts +6 -0
  304. package/extensions/sat-mining/src/signer-codec-manifest.ts +429 -0
  305. package/extensions/sat-mining/src/solana-submit.ts +1199 -428
  306. package/extensions/sat-mining/src/submission-ledger.ts +433 -0
  307. package/extensions/signal/package.json +1 -1
  308. package/extensions/synology-chat/package.json +1 -1
  309. package/extensions/tlon/package.json +1 -1
  310. package/extensions/twitch/CHANGELOG.md +12 -0
  311. package/extensions/twitch/package.json +1 -1
  312. package/extensions/voice-call/CHANGELOG.md +12 -0
  313. package/extensions/voice-call/package.json +1 -1
  314. package/extensions/zalo/CHANGELOG.md +12 -0
  315. package/extensions/zalo/package.json +1 -1
  316. package/extensions/zalouser/CHANGELOG.md +12 -0
  317. package/extensions/zalouser/package.json +1 -1
  318. package/install.sh +4955 -0
  319. package/package.json +23 -5
  320. package/scripts/fased-host-updater.mjs +1421 -0
  321. package/scripts/fased-host-updaterctl.mjs +186 -0
  322. package/scripts/fased-managed-service.sh +36 -0
  323. package/scripts/fased-managed-updater.mjs +3065 -156
  324. package/scripts/fased-signer-enroll-hosting.sh +275 -0
  325. package/scripts/fased-signer-network-hosting.sh +143 -0
  326. package/scripts/fased-signer-owner-policy.mjs +1430 -0
  327. package/scripts/fased-signer-policy-hosting.sh +50 -0
  328. package/scripts/fased-signer-policy-local.sh +26 -0
  329. package/scripts/hosted-release-manifest.mjs +185 -0
  330. package/scripts/install-fased-signerd.sh +183 -108
  331. package/scripts/install-managed-runtime.mjs +212 -10
  332. package/scripts/managed-runtime-layout.mjs +75 -3
  333. package/scripts/migrate-hosted-signer-v2.mjs +58 -0
  334. package/scripts/start-managed.sh +169 -360
  335. package/dist/control-ui/assets/mining-commit-R-6nu87p.js +0 -2
  336. package/dist/plugin-sdk/tmp-fased-dir-CS6zsYEE.d.ts +0 -77
  337. package/dist/wallet/local-socket-signer-broker.js +0 -224
  338. package/dist/wallet/local-socket-signer-custody.js +0 -42
  339. package/dist/wallet/providers/embedded-keystore-adapter.js +0 -402
  340. package/dist/wallet/providers/privy-adapter.js +0 -80
  341. 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 downloadVerifiedAsset({ releaseUrl, assetName, destinationDir, timeoutMs }) {
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
- try {
340
- await fsp.access(nodeModules);
341
- return nodeModules;
342
- } catch {
343
- // Download and activate below.
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 = `fased-hosted-deps-linux-${arch}-${dependencyHash}.tar.gz`;
346
- const archive = await downloadVerifiedAsset({
347
- releaseUrl,
348
- assetName,
349
- destinationDir: temporaryRoot,
350
- timeoutMs,
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
- async function refreshGateway(runtimeRoot, manifest, timeoutMs) {
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
- const installArgs = [cli, "gateway", "install", "--force"];
460
- if (manifest.profile === "hosting") {
461
- installArgs.push("--system");
462
- }
463
- const installed = await runFile(process.execPath, installArgs, { env, timeoutMs });
464
- if (!installed.ok) {
465
- throw new Error(
466
- `Gateway service installation failed: ${(installed.stderr || installed.stdout).trim()}`,
467
- );
468
- }
469
- const restarted = await runFile(process.execPath, [cli, "gateway", "restart"], {
470
- env,
471
- timeoutMs,
472
- });
473
- if (!restarted.ok) {
474
- throw new Error(`Gateway restart failed: ${(restarted.stderr || restarted.stdout).trim()}`);
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
- async function updateStableComponents(paths, runtimeRoot) {
516
- const scripts = path.join(runtimeRoot, "scripts");
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
- async function acquireUpdateLock(stateDir) {
527
- const lockPath = path.join(stateDir, "update.lock");
528
- await fsp.mkdir(stateDir, { recursive: true });
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
- const handle = await fsp.open(lockPath, "wx", 0o600);
531
- await handle.writeFile(
532
- `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
533
- );
1161
+ await handle.sync();
1162
+ } finally {
534
1163
  await handle.close();
535
- } catch (error) {
536
- if (error?.code !== "EEXIST") {
537
- throw error;
538
- }
539
- let active = false;
540
- try {
541
- const existing = JSON.parse(await fsp.readFile(lockPath, "utf8"));
542
- if (Number.isInteger(existing.pid)) {
543
- process.kill(existing.pid, 0);
544
- active = true;
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
- return async () => await fsp.rm(lockPath, { force: true });
1183
+ for (const directory of directories.toReversed()) {
1184
+ await fsyncManagedPath(directory);
1185
+ }
558
1186
  }
559
1187
 
560
- async function delegateToRuntime(args, paths) {
561
- const currentRoot = await resolveLinkTarget(paths.currentLink);
562
- if (!currentRoot) {
563
- throw new Error("Managed runtime is missing; run the official repair installer once.");
564
- }
565
- const result = await runFile(process.execPath, [path.join(currentRoot, "fased.mjs"), ...args], {
566
- env: {
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 cleanupReleases(paths, keepRoots) {
578
- const keep = new Set(keepRoots.filter(Boolean).map((value) => path.resolve(value)));
579
- const entries = await fsp.readdir(paths.releasesDir, { withFileTypes: true }).catch(() => []);
580
- for (const entry of entries) {
581
- if (
582
- !entry.isDirectory() ||
583
- !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:\.repair-\d+-\d+)?$/.test(
584
- entry.name,
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 updateManagedRuntime(options) {
597
- const commandStartedAt = Date.now();
598
- const timings = [];
599
- const paths = resolveManagedRuntimePaths();
600
- const existingManifest = readManagedInstallManifest(paths.manifestPath);
601
- if (!existingManifest) {
602
- throw new Error(
603
- "Managed installation manifest is missing; run the official repair installer once.",
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
- const currentRoot = await resolveLinkTarget(paths.currentLink);
607
- const currentVersion = currentRoot
608
- ? (await readPackageVersion(currentRoot)) || existingManifest.runtime.activeVersion
609
- : existingManifest.runtime.activeVersion;
610
- const targetVersion = await measureStage(timings, "release resolution", () =>
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
- if (options.status || options.dryRun) {
615
- const available = compareVersions(currentVersion, targetVersion) === -1;
616
- const result = {
617
- install: `managed ${existingManifest.profile}`,
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(currentRoot, existingManifest, options.timeoutMs);
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
- const assetName = `fased-hosted-app-linux-${arch}-v${targetVersion}.tar.gz`;
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
- downloadVerifiedAsset({
699
- releaseUrl,
700
- assetName,
701
- destinationDir: temporaryRoot,
702
- timeoutMs: options.timeoutMs,
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
- await measureStage(timings, "runtime activation", async () => {
764
- if (previousRoot && path.resolve(previousRoot) !== path.resolve(releaseRoot)) {
765
- await atomicSymlink(previousRoot, paths.previousLink);
3488
+ if (existingManifest.profile === "hosting") {
3489
+ if (!previousRoot) {
3490
+ throw new Error("Hosted transactional update requires an active previous runtime.");
766
3491
  }
767
- await atomicSymlink(releaseRoot, paths.currentLink);
768
- await replaceCompatibilityLink(paths);
769
- await atomicWriteJson(paths.manifestPath, nextManifest, 0o600);
770
- });
771
- activated = true;
772
-
773
- try {
774
- await measureStage(timings, "Gateway refresh and health", () =>
775
- refreshGateway(releaseRoot, nextManifest, options.timeoutMs),
776
- );
777
- } catch (error) {
778
- if (previousRoot) {
779
- await atomicSymlink(previousRoot, paths.currentLink);
780
- await replaceCompatibilityLink(paths);
781
- await atomicWriteJson(paths.manifestPath, previousManifest, 0o600);
782
- await refreshGateway(previousRoot, previousManifest, options.timeoutMs).catch(
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
- throw new Error(`Update rolled back after health verification failed: ${error.message}`, {
787
- cause: error,
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
- await measureStage(timings, "updater and rollback cleanup", async () => {
792
- await updateStableComponents(paths, releaseRoot);
793
- await cleanupReleases(paths, [releaseRoot, previousRoot]);
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
  };