@vantran-se/openchamber-web 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (442) hide show
  1. package/README.md +32 -24
  2. package/bin/cli.js +1 -3
  3. package/bin/cli.test.js +40 -16
  4. package/bin/lib/DOCUMENTATION.md +6 -5
  5. package/bin/lib/cli-args.js +6 -1
  6. package/bin/lib/cli-args.test.js +34 -0
  7. package/bin/lib/cli-lifecycle.js +0 -1
  8. package/bin/lib/cli-network.js +8 -2
  9. package/bin/lib/cli-process.js +1 -1
  10. package/bin/lib/cli-settings-accessors.test.js +21 -13
  11. package/bin/lib/cli-startup.js +17 -11
  12. package/bin/lib/cli-startup.test.js +92 -2
  13. package/bin/lib/commands-connect-url.js +8 -1
  14. package/bin/lib/commands-schedule.js +8 -2
  15. package/bin/lib/commands-serve.js +15 -21
  16. package/bin/lib/commands-update.js +12 -24
  17. package/bin/lib/commands-update.test.js +14 -46
  18. package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
  19. package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
  20. package/dist/assets/FilesView-BVoD8I2x.js +14 -0
  21. package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
  22. package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
  23. package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
  24. package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
  25. package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
  26. package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
  27. package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
  28. package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
  29. package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
  30. package/dist/assets/PlanView-RCBadcY7.js +5 -0
  31. package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
  32. package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
  33. package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
  34. package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
  35. package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
  36. package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
  37. package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
  38. package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
  39. package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
  40. package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
  41. package/dist/assets/de-YKfk5MmC.js +14 -0
  42. package/dist/assets/es-_gwT0kr-.js +20 -0
  43. package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
  44. package/dist/assets/fr-DqzvXmIm.js +18 -0
  45. package/dist/assets/index-BLaHKdtx.js +1 -0
  46. package/dist/assets/index-BkMXRrVQ.css +1 -0
  47. package/dist/assets/index-DxNa7xYB.js +65 -0
  48. package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
  49. package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
  50. package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
  51. package/dist/assets/ja-B-GFYhQE.js +20 -0
  52. package/dist/assets/ko-mzksfqDC.js +20 -0
  53. package/dist/assets/main-C5Xx8dn_.js +2 -0
  54. package/dist/assets/main-CfBSQM1H.js +990 -0
  55. package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
  56. package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
  57. package/dist/assets/mobile-DeEQd0wZ.js +2 -0
  58. package/dist/assets/nl-CWQr6NSq.js +20 -0
  59. package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
  60. package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
  61. package/dist/assets/pl-UmRcDUX2.js +20 -0
  62. package/dist/assets/pt-BR-BSLybvVF.js +20 -0
  63. package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
  64. package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
  65. package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
  66. package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
  67. package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
  68. package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
  69. package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
  70. package/dist/assets/space-apply-DM_2pRW7.js +1 -0
  71. package/dist/assets/space-close-Bpomt8z0.js +1 -0
  72. package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
  73. package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
  74. package/dist/assets/tr-gaTrX_HK.js +11 -0
  75. package/dist/assets/uk-CnJF6ykl.js +20 -0
  76. package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
  77. package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
  78. package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
  79. package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
  84. package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
  86. package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
  87. package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
  88. package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
  89. package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
  90. package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
  91. package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
  92. package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
  93. package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
  94. package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
  95. package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
  96. package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
  97. package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
  98. package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
  99. package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
  100. package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
  101. package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
  102. package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
  103. package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
  104. package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
  105. package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
  106. package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
  107. package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
  108. package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
  109. package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
  110. package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
  111. package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
  112. package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
  113. package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
  114. package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
  115. package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
  116. package/dist/assets/zh-CN-t2vigSB1.js +20 -0
  117. package/dist/assets/zh-TW-jAgqBokB.js +20 -0
  118. package/dist/index.html +6 -6
  119. package/dist/mini-chat.html +6 -6
  120. package/dist/mobile.html +6 -6
  121. package/package.json +8 -5
  122. package/server/index.js +80 -36
  123. package/server/lib/agent-memory/actions.js +1 -6
  124. package/server/lib/agent-memory/routes.js +1 -2
  125. package/server/lib/agent-memory/runtime.js +1 -8
  126. package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
  127. package/server/lib/agent-tool/runtime.js +3 -6
  128. package/server/lib/agent-tool/runtime.test.js +0 -2
  129. package/server/lib/browser-control/DOCUMENTATION.md +6 -0
  130. package/server/lib/changelog/update-notes.js +1 -1
  131. package/server/lib/context-obligatory/runtime.js +1 -2
  132. package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
  133. package/server/lib/dev-tunnel/client.js +21 -10
  134. package/server/lib/dev-tunnel/tunnel.test.js +34 -0
  135. package/server/lib/dispatch-results/runtime.js +1 -1
  136. package/server/lib/enterprise-mode.d.ts +1 -0
  137. package/server/lib/enterprise-mode.js +13 -2
  138. package/server/lib/enterprise-mode.test.js +21 -0
  139. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  140. package/server/lib/event-stream/index.js +1 -3
  141. package/server/lib/event-stream/translate-v2.js +1 -9
  142. package/server/lib/event-stream/translate-v2.test.js +0 -11
  143. package/server/lib/fs/DOCUMENTATION.md +5 -2
  144. package/server/lib/fs/routes.js +62 -15
  145. package/server/lib/fs/routes.test.js +278 -48
  146. package/server/lib/fs/workspace-file-names.js +104 -0
  147. package/server/lib/fs/workspace-file-names.test.js +62 -0
  148. package/server/lib/git/DOCUMENTATION.md +17 -5
  149. package/server/lib/git/contributor-provenance-storage.js +9 -6
  150. package/server/lib/git/contributor-provenance-storage.test.js +29 -0
  151. package/server/lib/git/credential-broker.js +1 -1
  152. package/server/lib/git/credential-resolver.js +1 -4
  153. package/server/lib/git/credentials.js +1 -0
  154. package/server/lib/git/discovery-endpoint.js +1 -1
  155. package/server/lib/git/identity-provisioning.js +2 -1
  156. package/server/lib/git/identity-storage.js +3 -4
  157. package/server/lib/git/lfs-discovery.js +14 -14
  158. package/server/lib/git/lfs-discovery.test.js +16 -6
  159. package/server/lib/git/network-operation-plan.js +50 -62
  160. package/server/lib/git/network-operation-plan.test.js +103 -1
  161. package/server/lib/git/network-operation-registry.js +4 -6
  162. package/server/lib/git/network-operation-storage.js +4 -6
  163. package/server/lib/git/network-operations.js +12 -4
  164. package/server/lib/git/network-operations.test.js +19 -6
  165. package/server/lib/git/redaction.js +2 -1
  166. package/server/lib/git/repository-credential-runtime.js +5 -3
  167. package/server/lib/git/repository-credential-runtime.test.js +22 -0
  168. package/server/lib/git/routes.js +31 -137
  169. package/server/lib/git/routes.test.js +2 -2
  170. package/server/lib/git/service.js +143 -27
  171. package/server/lib/git/service.test.js +215 -1
  172. package/server/lib/git/ssh-credential-storage.js +1 -4
  173. package/server/lib/git/submodule-discovery.js +1 -1
  174. package/server/lib/git/worktree-bootstrap-storage.js +3 -4
  175. package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
  176. package/server/lib/git/worktree-creation.js +194 -0
  177. package/server/lib/git/worktree-creation.test.js +155 -0
  178. package/server/lib/github/DOCUMENTATION.md +11 -6
  179. package/server/lib/github/auth.js +2 -4
  180. package/server/lib/github/device-flow.js +2 -2
  181. package/server/lib/github/gh-cli-credential.js +1 -0
  182. package/server/lib/github/octokit.js +31 -0
  183. package/server/lib/github/pr-status.js +35 -18
  184. package/server/lib/github/pr-status.test.js +271 -3
  185. package/server/lib/github/reference-search.js +102 -13
  186. package/server/lib/github/reference-search.test.js +48 -7
  187. package/server/lib/github/routes.auth.test.js +18 -0
  188. package/server/lib/github/routes.js +360 -6
  189. package/server/lib/github/routes.mutations.test.js +239 -0
  190. package/server/lib/github/routes.test.js +134 -2
  191. package/server/lib/gitlab/DOCUMENTATION.md +5 -1
  192. package/server/lib/gitlab/auth-storage.test.js +1 -0
  193. package/server/lib/gitlab/mappers.js +30 -1
  194. package/server/lib/gitlab/resources.js +230 -6
  195. package/server/lib/gitlab/resources.test.js +179 -0
  196. package/server/lib/gitlab/routes.js +200 -6
  197. package/server/lib/gitlab/routes.test.js +186 -2
  198. package/server/lib/gitlab/validation.js +1 -5
  199. package/server/lib/guests/DOCUMENTATION.md +1 -1
  200. package/server/lib/guests/oauth.js +2 -2
  201. package/server/lib/guests/service.js +1 -1
  202. package/server/lib/guests/sockets.js +1 -1
  203. package/server/lib/injected-env.d.ts +8 -0
  204. package/server/lib/injected-env.js +50 -0
  205. package/server/lib/injected-env.test.js +56 -0
  206. package/server/lib/linear/DOCUMENTATION.md +5 -6
  207. package/server/lib/linear/issues.js +62 -4
  208. package/server/lib/linear/issues.test.js +68 -0
  209. package/server/lib/linear/oauth.js +5 -5
  210. package/server/lib/linear/parse.js +2 -9
  211. package/server/lib/message-queue/DOCUMENTATION.md +23 -1
  212. package/server/lib/message-queue/runtime.js +85 -12
  213. package/server/lib/message-queue/runtime.test.js +172 -2
  214. package/server/lib/notifications/apns-runtime.js +5 -6
  215. package/server/lib/notifications/apns-runtime.test.js +20 -4
  216. package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
  217. package/server/lib/openchamber-control/actions.js +2 -1
  218. package/server/lib/openchamber-control/service.js +119 -20
  219. package/server/lib/openchamber-control/service.test.js +166 -0
  220. package/server/lib/openchamber-sessions/archive-store.js +1 -6
  221. package/server/lib/openchamber-sessions/routes.js +47 -12
  222. package/server/lib/openchamber-sessions/routes.test.js +65 -2
  223. package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
  224. package/server/lib/opencode/DOCUMENTATION.md +22 -8
  225. package/server/lib/opencode/agents.js +62 -0
  226. package/server/lib/opencode/auth-state-runtime.js +3 -1
  227. package/server/lib/opencode/auth.d.ts +1 -1
  228. package/server/lib/opencode/auth.js +8 -0
  229. package/server/lib/opencode/auth.test.js +31 -0
  230. package/server/lib/opencode/config-entity-routes.js +16 -0
  231. package/server/lib/opencode/config-entity-v2.test.js +16 -1
  232. package/server/lib/opencode/config-paths.test.js +2 -2
  233. package/server/lib/opencode/config-v2.js +2 -4
  234. package/server/lib/opencode/feature-routes-runtime.js +89 -49
  235. package/server/lib/opencode/lifecycle.js +41 -21
  236. package/server/lib/opencode/lifecycle.test.js +50 -2
  237. package/server/lib/opencode/model-discovery.js +1 -1
  238. package/server/lib/opencode/model-discovery.test.js +17 -0
  239. package/server/lib/opencode/models-metadata.js +0 -2
  240. package/server/lib/opencode/openchamber-routes.test.js +3 -3
  241. package/server/lib/opencode/plugins.js +6 -9
  242. package/server/lib/opencode/provider-source-route.test.js +101 -3
  243. package/server/lib/opencode/providers-disabled.test.js +59 -0
  244. package/server/lib/opencode/providers.js +34 -0
  245. package/server/lib/opencode/proxy.js +11 -3
  246. package/server/lib/opencode/routes.js +40 -2
  247. package/server/lib/opencode/settings-helpers.js +27 -1
  248. package/server/lib/opencode/settings-helpers.test.js +19 -7
  249. package/server/lib/opencode/settings-registry.json +27 -16
  250. package/server/lib/opencode/settings-runtime.test.js +18 -0
  251. package/server/lib/opencode/shared-service-runtime.js +23 -104
  252. package/server/lib/opencode/shared.js +77 -46
  253. package/server/lib/opencode/shared.test.js +12 -12
  254. package/server/lib/opencode/shutdown-runtime.test.js +2 -3
  255. package/server/lib/opencode/skill-routes.js +123 -38
  256. package/server/lib/opencode/skill-routes.test.js +201 -0
  257. package/server/lib/opencode/skills.js +81 -34
  258. package/server/lib/opencode/skills.test.js +176 -21
  259. package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
  260. package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
  261. package/server/lib/opencode/watcher.test.js +0 -54
  262. package/server/lib/outbound-proxy.js +52 -0
  263. package/server/lib/outbound-proxy.test.js +55 -0
  264. package/server/lib/package-manager.js +23 -17
  265. package/server/lib/package-manager.test.js +5 -18
  266. package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
  267. package/server/lib/permission-auto-accept/runtime.js +19 -47
  268. package/server/lib/permission-auto-accept/runtime.test.js +2 -2
  269. package/server/lib/project-context/DOCUMENTATION.md +7 -1
  270. package/server/lib/project-context/routes.js +1 -2
  271. package/server/lib/project-context/runtime.js +12 -14
  272. package/server/lib/project-context/runtime.test.js +78 -2
  273. package/server/lib/projects/project-config.js +30 -15
  274. package/server/lib/projects/project-config.test.js +30 -0
  275. package/server/lib/projects/project-setup.js +1 -2
  276. package/server/lib/projects/routes.js +1 -2
  277. package/server/lib/quota/DOCUMENTATION.md +35 -7
  278. package/server/lib/quota/index.js +1 -0
  279. package/server/lib/quota/providers/claude/index.js +1 -1
  280. package/server/lib/quota/providers/cline-pass.js +1 -1
  281. package/server/lib/quota/providers/exe-dev.js +1 -1
  282. package/server/lib/quota/providers/google/index.js +1 -1
  283. package/server/lib/quota/providers/hyper.js +1 -1
  284. package/server/lib/quota/providers/kimi.js +116 -36
  285. package/server/lib/quota/providers/kimi.test.js +128 -3
  286. package/server/lib/quota/providers/opencode-go.js +144 -21
  287. package/server/lib/quota/providers/opencode-go.test.js +229 -6
  288. package/server/lib/quota/providers/openrouter.js +1 -1
  289. package/server/lib/quota/providers/zenmux.js +1 -1
  290. package/server/lib/quota/utils/auth.js +1 -0
  291. package/server/lib/quota/utils/transformers.js +2 -6
  292. package/server/lib/relay/DOCUMENTATION.md +18 -3
  293. package/server/lib/relay/e2ee.js +7 -7
  294. package/server/lib/relay/host-client.js +84 -13
  295. package/server/lib/relay/host-client.test.js +201 -1
  296. package/server/lib/relay/identity.js +6 -29
  297. package/server/lib/relay/identity.test.js +18 -28
  298. package/server/lib/relay/key-store.js +141 -0
  299. package/server/lib/relay/key-store.test.js +138 -0
  300. package/server/lib/relay/service.js +8 -8
  301. package/server/lib/relay/service.test.js +10 -5
  302. package/server/lib/relay/signing-key.js +13 -36
  303. package/server/lib/relay/tunnel-codec.js +10 -10
  304. package/server/lib/route-module-exports.test.js +22 -0
  305. package/server/lib/routing/DOCUMENTATION.md +23 -3
  306. package/server/lib/routing/cache.js +87 -0
  307. package/server/lib/routing/defaults.js +11 -0
  308. package/server/lib/routing/history.js +12 -4
  309. package/server/lib/routing/runtime.js +39 -3
  310. package/server/lib/routing/runtime.test.js +81 -2
  311. package/server/lib/routing/store.js +12 -1
  312. package/server/lib/routing/store.test.js +13 -0
  313. package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
  314. package/server/lib/scheduled-tasks/existing-session.js +119 -0
  315. package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
  316. package/server/lib/scheduled-tasks/loops.js +1 -8
  317. package/server/lib/scheduled-tasks/routes.js +8 -8
  318. package/server/lib/scheduled-tasks/runtime.js +103 -13
  319. package/server/lib/scheduled-tasks/runtime.test.js +48 -0
  320. package/server/lib/scheduled-tasks/service.js +33 -11
  321. package/server/lib/scheduled-tasks/service.test.js +128 -2
  322. package/server/lib/scheduled-tasks/session-defaults.js +27 -0
  323. package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
  324. package/server/lib/session-assist/context.js +1 -0
  325. package/server/lib/session-folders/routes.js +2 -2
  326. package/server/lib/session-goal/objectives.js +1 -1
  327. package/server/lib/session-knowledge/routes.js +2 -2
  328. package/server/lib/session-knowledge/runtime.js +1 -2
  329. package/server/lib/session-work/DOCUMENTATION.md +39 -14
  330. package/server/lib/session-work/questions.js +31 -2
  331. package/server/lib/session-work/runtime.js +22 -13
  332. package/server/lib/session-work/runtime.test.js +43 -26
  333. package/server/lib/session-work/state.js +10 -4
  334. package/server/lib/session-work/state.test.js +8 -1
  335. package/server/lib/shared/DOCUMENTATION.md +89 -0
  336. package/server/lib/shared/guards.js +53 -0
  337. package/server/lib/shared/guards.test.js +112 -0
  338. package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
  339. package/server/lib/skills-catalog/cache.js +3 -2
  340. package/server/lib/skills-catalog/curated-sources.js +5 -1
  341. package/server/lib/skills-catalog/git.js +107 -20
  342. package/server/lib/skills-catalog/git.test.js +78 -0
  343. package/server/lib/skills-catalog/install.js +21 -12
  344. package/server/lib/skills-catalog/scan.js +25 -15
  345. package/server/lib/skills-catalog/source.js +26 -1
  346. package/server/lib/skills-catalog/source.test.js +27 -0
  347. package/server/lib/source-control/DOCUMENTATION.md +2 -1
  348. package/server/lib/source-control/audit-storage.js +7 -6
  349. package/server/lib/source-control/audit-storage.test.js +11 -0
  350. package/server/lib/source-control/binding-service.js +2 -5
  351. package/server/lib/source-control/change-request-worktree.js +90 -0
  352. package/server/lib/source-control/change-request-worktree.test.js +121 -0
  353. package/server/lib/source-control/mutation-executor.js +1 -1
  354. package/server/lib/source-control/mutation-storage.js +11 -5
  355. package/server/lib/source-control/provider-instance.js +1 -2
  356. package/server/lib/source-control/repository-identity.js +1 -1
  357. package/server/lib/source-control/routes.js +14 -2
  358. package/server/lib/spaces/DOCUMENTATION.md +42 -17
  359. package/server/lib/spaces/command-stream.test.js +15 -0
  360. package/server/lib/spaces/dispatcher.js +7 -0
  361. package/server/lib/spaces/dispatcher.test.js +17 -0
  362. package/server/lib/spaces/hardening.js +2 -2
  363. package/server/lib/spaces/hardening.test.js +1 -1
  364. package/server/lib/spaces/host.js +2 -0
  365. package/server/lib/spaces/journey.js +66 -5
  366. package/server/lib/spaces/journey.test.js +95 -4
  367. package/server/lib/spaces/layout.js +1 -1
  368. package/server/lib/spaces/places/docker-disk.js +38 -14
  369. package/server/lib/spaces/places/docker-disk.test.js +80 -1
  370. package/server/lib/spaces/places/docker.js +35 -11
  371. package/server/lib/spaces/places/fake-docker.js +22 -3
  372. package/server/lib/spaces/places/memory-place.js +3 -0
  373. package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
  374. package/server/lib/spaces/routes.js +5 -0
  375. package/server/lib/spaces/routes.test.js +4 -2
  376. package/server/lib/spaces/run-command.js +9 -2
  377. package/server/lib/spaces/tools.js +4 -5
  378. package/server/lib/spaces/tools.test.js +2 -2
  379. package/server/lib/terminal/runtime.js +18 -6
  380. package/server/lib/terminal/runtime.test.js +66 -0
  381. package/server/lib/tts/index.js +3 -0
  382. package/server/lib/tts/service.js +4 -2
  383. package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
  384. package/server/lib/walkthrough/index.js +2 -3
  385. package/server/lib/walkthrough/pr-source.test.js +22 -0
  386. package/server/lib/walkthrough/sources.js +6 -1
  387. package/server/opencode-proxy.test.js +127 -0
  388. package/bin/lib/commands-startup.test.js +0 -15
  389. package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
  390. package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
  391. package/dist/assets/FilesView-DT0uEYgT.js +0 -17
  392. package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
  393. package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
  394. package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
  395. package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
  396. package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
  397. package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
  398. package/dist/assets/PlanView-DsRWnsOI.js +0 -5
  399. package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
  400. package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
  401. package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
  402. package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
  403. package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
  404. package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
  405. package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
  406. package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
  407. package/dist/assets/de-CBla4hjd.js +0 -14
  408. package/dist/assets/es-BmnZtxTD.js +0 -20
  409. package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
  410. package/dist/assets/fr-OVKIBUZO.js +0 -18
  411. package/dist/assets/index-CKCFKmjF.js +0 -1
  412. package/dist/assets/index-Dbx4V4GN.css +0 -1
  413. package/dist/assets/index-vQ1Pg1cr.js +0 -61
  414. package/dist/assets/ja-6v-yr-gE.js +0 -20
  415. package/dist/assets/ko-I_ZLvgQ9.js +0 -20
  416. package/dist/assets/main-CmO4dRtR.js +0 -2
  417. package/dist/assets/main-b9vqhlYM.js +0 -999
  418. package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
  419. package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
  420. package/dist/assets/mobile-CVtShif9.js +0 -2
  421. package/dist/assets/nl-DcvIJf46.js +0 -20
  422. package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
  423. package/dist/assets/pl-JxCUl0sd.js +0 -20
  424. package/dist/assets/pt-BR-L89mjja2.js +0 -20
  425. package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
  426. package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
  427. package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
  428. package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
  429. package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
  430. package/dist/assets/space-apply-1trGVIl8.js +0 -1
  431. package/dist/assets/space-close-y7j-NuBv.js +0 -1
  432. package/dist/assets/tr-ka5J1flt.js +0 -11
  433. package/dist/assets/uk-PyGe6Q_u.js +0 -20
  434. package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
  435. package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
  436. package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
  437. package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
  438. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
  439. package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
  440. package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
  441. package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
  442. package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
@@ -21,8 +21,9 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
21
21
  Host side (`packages/web/server/lib/relay/`):
22
22
  - `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay (`pinnedRelayUrl`: `relayUrl` in the machine policy file, else the `OPENCHAMBER_RELAY_URL` env var; see `../enterprise-mode.js`; must be `ws://`/`wss://`, an invalid value pins nothing); when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. In enterprise mode (`../enterprise-mode.js`) the relay runs only with that pin (`relayBlockedByEnterprise`): `start()` refuses otherwise (every path in passes through it), `enable` answers 403, `getPairingCandidate()` is null, `ensureEnabledForPairing()` throws (the pairing route drops the candidate and direct pairing still works), status carries `blockedByEnterprise`, and `resolvePairingTransports` in `index.js` reports `relayAvailable: false`.
23
23
  - `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
24
- - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
25
- - `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
24
+ - `signing-key.js` — the signing keypair and the routing id derivation, shared with the notifications runtime.
25
+ - `key-store.js` — where both keypairs live: `<data-dir>/relay-identity.json` (`0600`), never `settings.json`, so the private keys do not travel with every copy of the settings file. Installs that kept them in `settings.json` (`relaySigningKey` / `relayEncryptionKey`) have them moved on first load, unchanged so `serverId` and the trust anchor survive, then stripped from settings; an older build sharing the data dir then mints its own identity. A key is minted only when neither file holds it, and both are read strictly so a corrupt or unreadable file fails instead of reading as first run. `index.js` builds one store for the push relay and the private relay, so their concurrent first loads mint one key.
26
+ - `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection. A data connection dial that never opens (refused, or no answer within 5 s, doubling to 15 s per failure) is retried after 1 s, doubling to 8 s, for as long as the relay still reports that client and the control connection is up. The relay sends no second `connected` for a waiting client, so without the retry the device waits for the relay's 15 s stuck-control reset or its own 30 s handshake timeout. A data connection that opened and then closed is not retried: the relay closes the client with it, and the client's reconnect arrives as a new `connected`.
26
27
  - `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
27
28
  - `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
28
29
  - `downstream-scheduler.js` owns end-client delivery credit and round-robin transmission across response streams. It selects plaintext frames before encryption.
@@ -164,7 +165,20 @@ occur transiently in the field (a same-identity dial taking the leg, relay state
164
165
  left by a host restart), so the client keeps its wake listeners armed and the
165
166
  next `online` event or return to the foreground clears the error and makes one
166
167
  fresh attempt. There is still no background loop. Transient failures use the
167
- existing reconnect/backoff path.
168
+ existing reconnect/backoff path. The client status marks such a parked error
169
+ with `terminal: true`, so callers can tell it apart from a pending reconnect.
170
+
171
+ Mobile connect flows (`packages/ui/src/apps/mobileConnections.ts`, helpers in
172
+ `mobileRelayFetch.ts`) log the relay failure reason (close message or
173
+ `timeout`) in the connect trail. Idempotent probes (`/health`,
174
+ `/auth/session`) wait for the tunnel's own reconnect after a dropped socket and
175
+ ask again within their budget; pairing redeem and password login are sent once.
176
+ Pairing, logging in again, and the session probes of auto-connect and connect
177
+ ride the connected runtime tunnel when the phone is already on that server
178
+ through the relay, and do not open a second tunnel to the same server: the
179
+ relay keeps one client leg per device and server, so a second dial drops the
180
+ live session. A probe dials its own tunnel only when the live one gives no
181
+ answer.
168
182
 
169
183
  Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected.
170
184
 
@@ -172,6 +186,7 @@ Relay mode plugs into the existing client transport layer rather than a parallel
172
186
 
173
187
  - The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
174
188
  - Pairing secrets travel in URL fragments only, never in query strings, never logged.
189
+ - Host dial URLs carry the signed relay auth in their query string, and Bun's WebSocket copies the whole URL into its error messages. Relay errors reach logs and the relay status only through `describeRelayError` in `host-client.js`, which strips query strings and userinfo from every URL in the text.
175
190
  - The host dispatcher never injects credentials; the server authenticates each tunneled request.
176
191
  - The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
177
192
  - The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
@@ -9,12 +9,12 @@
9
9
  const subtle = globalThis.crypto.subtle;
10
10
 
11
11
  export const RELAY_PROTOCOL_VERSION = 1;
12
- export const RELAY_HKDF_INFO = 'openchamber-relay-v1';
12
+ const RELAY_HKDF_INFO = 'openchamber-relay-v1';
13
13
 
14
14
  // Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
15
- export const ENCRYPTED_FRAME_VERSION = 1;
16
- export const ENCRYPTED_FRAME_IV_BYTES = 12;
17
- export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
15
+ const ENCRYPTED_FRAME_VERSION = 1;
16
+ const ENCRYPTED_FRAME_IV_BYTES = 12;
17
+ const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
18
18
  export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
19
19
 
20
20
  // Relay-assigned WebSocket close codes (subset the host needs).
@@ -31,7 +31,7 @@ const GCM_TAG_BYTES = 16;
31
31
  const IV_PREFIX_BYTES = 4;
32
32
  const IV_COUNTER_BYTES = 8;
33
33
 
34
- export class RelayCryptoError extends Error {
34
+ class RelayCryptoError extends Error {
35
35
  constructor(message) {
36
36
  super(message);
37
37
  this.name = 'RelayCryptoError';
@@ -51,7 +51,7 @@ export const exportPublicKeyJwk = async (key) => {
51
51
  };
52
52
 
53
53
  /** @param {JsonWebKey} jwk */
54
- export const importEcdhPublicKey = async (jwk) => {
54
+ const importEcdhPublicKey = async (jwk) => {
55
55
  if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
56
56
  throw new RelayCryptoError('invalid ECDH public key JWK');
57
57
  }
@@ -79,7 +79,7 @@ export const importEcdhPrivateKey = async (jwk) => {
79
79
 
80
80
  // Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
81
81
  /** @param {JsonWebKey} jwk */
82
- export const publicKeyJwkFingerprint = (jwk) =>
82
+ const publicKeyJwkFingerprint = (jwk) =>
83
83
  JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
84
84
 
85
85
  export const generateHandshakeNonce = () => {
@@ -10,10 +10,20 @@ import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e
10
10
  import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
11
11
  import { createTunnelHost } from './tunnel-host.js';
12
12
  import { createDownstreamScheduler, DOWNSTREAM_CHUNK_BYTES } from './downstream-scheduler.js';
13
+ import { redactSensitiveText } from '../source-control/url-redaction.js';
13
14
 
14
15
  const BACKOFF_BASE_MS = 1000;
15
16
  const BACKOFF_CAP_MS = 30000;
17
+ // A data socket dial that never opens is retried while the relay still reports
18
+ // its client: the relay holds the client and buffers its frames, and sends no
19
+ // new `connected` for it. Without a retry the client waits for the relay's 15 s
20
+ // stuck-control reset, or for its own 30 s handshake timeout when that fails too.
21
+ // The open timeout starts short so a hung dial leaves room for retries inside
22
+ // that 30 s, and doubles per failed dial so a slow network still gets 15 s.
23
+ const DATA_SOCKET_FIRST_OPEN_TIMEOUT_MS = 5000;
16
24
  const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
25
+ const DATA_SOCKET_REDIAL_BASE_MS = 1000;
26
+ const DATA_SOCKET_REDIAL_CAP_MS = 8000;
17
27
  // Clients send a tunnel Ping at least every ~30s when idle, so a data socket
18
28
  // with no inbound traffic for 3 ping intervals belongs to a client that died
19
29
  // without a WebSocket close (network loss, battery kill). The relay worker may
@@ -40,6 +50,11 @@ const resolveBatchWindowMs = (option) => {
40
50
  return DEFAULT_BATCH_WINDOW_MS;
41
51
  };
42
52
 
53
+ // Bun's WebSocket puts the full dial URL into its error messages, and that URL
54
+ // carries the signed relay auth in its query string. Every relay error passes
55
+ // through here before it reaches a log line or the relay status.
56
+ export const describeRelayError = (error) => redactSensitiveText(error?.message ?? error);
57
+
43
58
  /**
44
59
  * @param {{
45
60
  * relayUrl: string,
@@ -48,9 +63,10 @@ const resolveBatchWindowMs = (option) => {
48
63
  * getLocalPort?: () => number,
49
64
  * onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
50
65
  * logger?: Pick<Console, 'warn'>,
66
+ * createSocket?: (url: string) => WebSocket,
51
67
  * }} options
52
68
  */
53
- export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
69
+ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, createSocket = (url) => new WebSocket(url), batchWindowMs, batch, flowControl }) => {
54
70
  const { version } = createRequire(import.meta.url)('../../../package.json');
55
71
  const platform = process.env.OPENCHAMBER_RUNTIME || 'web';
56
72
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
@@ -65,6 +81,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
65
81
  let consecutiveFailures = 0;
66
82
  /** @type {Map<string, { socket: WebSocket, tunnel: ReturnType<typeof createTunnelHost> | null, openTimer: NodeJS.Timeout | null }>} */
67
83
  const dataSockets = new Map();
84
+ // Clients the relay reported (`connected`/`sync`) and has not reported gone.
85
+ /** @type {Map<string, { failedDials: number, redialTimer: NodeJS.Timeout | null }>} */
86
+ const announcedClients = new Map();
68
87
 
69
88
  const emitStatus = () => {
70
89
  try {
@@ -97,10 +116,43 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
97
116
  return url.toString();
98
117
  };
99
118
 
119
+ const announceClient = (connectionId) => {
120
+ if (!announcedClients.has(connectionId)) announcedClients.set(connectionId, { failedDials: 0, redialTimer: null });
121
+ };
122
+
123
+ const forgetClient = (connectionId) => {
124
+ const client = announcedClients.get(connectionId);
125
+ if (client?.redialTimer) clearTimeout(client.redialTimer);
126
+ announcedClients.delete(connectionId);
127
+ };
128
+
129
+ const cancelRedials = () => {
130
+ for (const client of announcedClients.values()) {
131
+ if (client.redialTimer) clearTimeout(client.redialTimer);
132
+ client.redialTimer = null;
133
+ }
134
+ };
135
+
136
+ // Only a dial that never opened is retried. Once the relay attached a data
137
+ // socket, its close makes the relay close the client too, and the client's
138
+ // reconnect arrives as a new `connected`. With the control socket down the
139
+ // next `sync` decides which clients still wait.
140
+ const scheduleRedial = (connectionId) => {
141
+ const client = announcedClients.get(connectionId);
142
+ if (stopped || !client || client.redialTimer || controlSocket?.readyState !== WebSocket.OPEN) return;
143
+ const delay = Math.min(DATA_SOCKET_REDIAL_BASE_MS * 2 ** client.failedDials, DATA_SOCKET_REDIAL_CAP_MS);
144
+ client.failedDials += 1;
145
+ client.redialTimer = setTimeout(() => {
146
+ client.redialTimer = null;
147
+ openDataSocket(connectionId);
148
+ }, delay);
149
+ };
150
+
100
151
  const teardownDataSocket = (connectionId, closeCode, reason) => {
101
152
  const entry = dataSockets.get(connectionId);
102
153
  if (!entry) return;
103
154
  dataSockets.delete(connectionId);
155
+ if (!entry.opened) scheduleRedial(connectionId);
104
156
  if (entry.openTimer) clearTimeout(entry.openTimer);
105
157
  entry.batcher?.dispose();
106
158
  entry.scheduler?.close();
@@ -118,21 +170,28 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
118
170
 
119
171
  const openDataSocket = (connectionId) => {
120
172
  if (stopped || dataSockets.has(connectionId)) return;
173
+ const client = announcedClients.get(connectionId);
174
+ if (client?.redialTimer) {
175
+ clearTimeout(client.redialTimer);
176
+ client.redialTimer = null;
177
+ }
121
178
 
122
179
  let socket;
123
180
  try {
124
- socket = new WebSocket(buildSocketUrl('host-data', connectionId));
181
+ socket = createSocket(buildSocketUrl('host-data', connectionId));
125
182
  } catch (error) {
126
- logger.warn(`[Relay] host-data dial failed: ${error?.message ?? error}`);
183
+ logger.warn(`[Relay] host-data dial failed: ${describeRelayError(error)}`);
184
+ scheduleRedial(connectionId);
127
185
  return;
128
186
  }
129
187
 
130
- const entry = { socket, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
188
+ const entry = { socket, opened: false, tunnel: null, openTimer: null, batcher: null, scheduler: null, lastActivityAt: Date.now() };
131
189
  dataSockets.set(connectionId, entry);
190
+ const openTimeoutMs = Math.min(DATA_SOCKET_FIRST_OPEN_TIMEOUT_MS * 2 ** (client?.failedDials ?? 0), DATA_SOCKET_OPEN_TIMEOUT_MS);
132
191
  entry.openTimer = setTimeout(() => {
133
192
  logger.warn('[Relay] host-data socket open timeout');
134
193
  teardownDataSocket(connectionId);
135
- }, DATA_SOCKET_OPEN_TIMEOUT_MS);
194
+ }, openTimeoutMs);
136
195
 
137
196
  const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch, flowControl });
138
197
  let channel = null;
@@ -153,7 +212,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
153
212
  socket.send(encrypted, { binary: true });
154
213
  })
155
214
  .catch((error) => {
156
- logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
215
+ logger.warn(`[Relay] host-data send failed: ${describeRelayError(error)}`);
157
216
  failChannel(RelayCloseCode.ChannelFailure, 'send failed');
158
217
  });
159
218
  return sendChain;
@@ -231,7 +290,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
231
290
  await dispatchFrame(plaintext);
232
291
  }
233
292
  } catch (error) {
234
- logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
293
+ logger.warn(`[Relay] tunnel frame handling failed: ${describeRelayError(error)}`);
235
294
  failChannel(RelayCloseCode.ChannelFailure, 'invalid tunnel frame');
236
295
  }
237
296
  };
@@ -256,21 +315,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
256
315
  clearTimeout(entry.openTimer);
257
316
  entry.openTimer = null;
258
317
  }
318
+ entry.opened = true;
319
+ const current = announcedClients.get(connectionId);
320
+ if (current) current.failedDials = 0;
259
321
  emitStatus();
260
322
  });
261
323
  socket.on('message', (data, isBinary) => {
262
324
  processing = processing
263
325
  .then(() => handleMessage(data, isBinary))
264
326
  .catch((error) => {
265
- logger.warn(`[Relay] data socket message failed: ${error?.message ?? error}`);
327
+ logger.warn(`[Relay] data socket message failed: ${describeRelayError(error)}`);
266
328
  failChannel(RelayCloseCode.ChannelFailure, 'internal error');
267
329
  });
268
330
  });
269
331
  socket.on('close', () => {
270
- teardownDataSocket(connectionId);
332
+ // A retry may already own this connectionId.
333
+ if (dataSockets.get(connectionId) === entry) teardownDataSocket(connectionId);
271
334
  });
272
335
  socket.on('error', (error) => {
273
- logger.warn(`[Relay] host-data socket error: ${error?.message ?? error}`);
336
+ logger.warn(`[Relay] host-data socket error: ${describeRelayError(error)}`);
274
337
  });
275
338
  };
276
339
 
@@ -284,19 +347,25 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
284
347
  if (!message || typeof message !== 'object') return;
285
348
  if (message.type === 'sync' && Array.isArray(message.connectionIds)) {
286
349
  const wanted = new Set(message.connectionIds.filter((id) => typeof id === 'string' && id.length > 0));
350
+ for (const connectionId of [...announcedClients.keys()]) {
351
+ if (!wanted.has(connectionId)) forgetClient(connectionId);
352
+ }
287
353
  for (const connectionId of [...dataSockets.keys()]) {
288
354
  if (!wanted.has(connectionId)) teardownDataSocket(connectionId);
289
355
  }
290
356
  for (const connectionId of wanted) {
357
+ announceClient(connectionId);
291
358
  openDataSocket(connectionId);
292
359
  }
293
360
  return;
294
361
  }
295
362
  if (message.type === 'connected' && typeof message.connectionId === 'string') {
363
+ announceClient(message.connectionId);
296
364
  openDataSocket(message.connectionId);
297
365
  return;
298
366
  }
299
367
  if (message.type === 'disconnected' && typeof message.connectionId === 'string') {
368
+ forgetClient(message.connectionId);
300
369
  teardownDataSocket(message.connectionId);
301
370
  }
302
371
  };
@@ -318,9 +387,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
318
387
 
319
388
  let socket;
320
389
  try {
321
- socket = new WebSocket(buildSocketUrl('host-control'));
390
+ socket = createSocket(buildSocketUrl('host-control'));
322
391
  } catch (error) {
323
- lastError = error?.message ?? String(error);
392
+ lastError = describeRelayError(error);
324
393
  scheduleReconnect();
325
394
  return;
326
395
  }
@@ -365,7 +434,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
365
434
  });
366
435
  socket.on('error', (error) => {
367
436
  if (controlSocket !== socket) return;
368
- lastError = error?.message ?? String(error);
437
+ lastError = describeRelayError(error);
369
438
  });
370
439
  socket.on('close', (code, reasonBuffer) => {
371
440
  clearInterval(pingTimer);
@@ -377,6 +446,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
377
446
  }
378
447
  // Data sockets ride their own relay connections; the relay keeps clients
379
448
  // alive through a 30 s control-reconnect grace window, so leave them up.
449
+ cancelRedials();
380
450
  scheduleReconnect();
381
451
  });
382
452
  };
@@ -397,6 +467,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
397
467
  if (stopped) return;
398
468
  stopped = true;
399
469
  clearInterval(idleSweepTimer);
470
+ cancelRedials();
400
471
  if (reconnectTimer) {
401
472
  clearTimeout(reconnectTimer);
402
473
  reconnectTimer = null;
@@ -2,9 +2,10 @@
2
2
  // client using the JS e2ee initiator. Verifies the full handshake and a tunneled
3
3
  // HTTP GET /health, and asserts only binary frames cross the relay post-handshake.
4
4
 
5
- import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
5
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test';
6
6
  import http from 'node:http';
7
7
  import crypto from 'node:crypto';
8
+ import { EventEmitter } from 'node:events';
8
9
  import { WebSocket, WebSocketServer } from 'ws';
9
10
 
10
11
  import { startRelayHost } from './host-client.js';
@@ -315,3 +316,202 @@ describe('relay host-client integration', () => {
315
316
  expect(forwarded.filter((f) => f.isBinary).length).toBeGreaterThan(0);
316
317
  });
317
318
  });
319
+
320
+ // Control-only relay whose host-data upgrades the test scripts: each dial is
321
+ // answered by the next entry of `outcomes` ('reject' -> 503, 'hang' -> no
322
+ // answer, 'accept' -> 101),
323
+ // accepting once the list runs out.
324
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
325
+
326
+ const startScriptedRelay = (outcomes) => {
327
+ const server = http.createServer();
328
+ const wss = new WebSocketServer({ noServer: true });
329
+ const state = { control: null, dials: [], dataSockets: [] };
330
+ // Upgraded sockets leave the server's connection tracking, so a hung one
331
+ // would hold server.close() open: destroy them on stop.
332
+ const hungSockets = [];
333
+ let notifyDial = () => {};
334
+ server.on('upgrade', (req, socket, head) => {
335
+ const url = new URL(req.url, 'http://localhost');
336
+ const role = url.searchParams.get('role');
337
+ if (role === 'host-data') {
338
+ state.dials.push(url.searchParams.get('connectionId'));
339
+ notifyDial();
340
+ const outcome = outcomes.shift() ?? 'accept';
341
+ if (outcome === 'reject') {
342
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n');
343
+ return;
344
+ }
345
+ if (outcome === 'hang') {
346
+ socket.on('error', () => {});
347
+ hungSockets.push(socket);
348
+ return;
349
+ }
350
+ }
351
+ wss.handleUpgrade(req, socket, head, (ws) => {
352
+ if (role === 'host-control') state.control = ws;
353
+ else state.dataSockets.push(ws);
354
+ });
355
+ });
356
+ const waitForDials = (count, timeoutMs) => new Promise((resolve) => {
357
+ const check = () => {
358
+ if (state.dials.length >= count) resolve(true);
359
+ };
360
+ notifyDial = check;
361
+ check();
362
+ setTimeout(() => resolve(state.dials.length >= count), timeoutMs);
363
+ });
364
+ return new Promise((resolve) => {
365
+ server.listen(0, '127.0.0.1', () => {
366
+ resolve({
367
+ wsUrl: `ws://127.0.0.1:${server.address().port}/`,
368
+ state,
369
+ waitForDials,
370
+ send: (message) => state.control.send(JSON.stringify(message)),
371
+ stop: () => new Promise((r) => {
372
+ for (const client of wss.clients) client.terminate();
373
+ for (const socket of hungSockets) socket.destroy();
374
+ server.closeAllConnections();
375
+ wss.close();
376
+ server.close(() => r());
377
+ }),
378
+ });
379
+ });
380
+ });
381
+ };
382
+
383
+ describe('relay host-client data socket retry', () => {
384
+ let relay;
385
+ let host;
386
+
387
+ afterEach(async () => {
388
+ host?.stop();
389
+ host = null;
390
+ await relay?.stop();
391
+ relay = null;
392
+ });
393
+
394
+ const startHost = async (outcomes) => {
395
+ relay = await startScriptedRelay(outcomes);
396
+ const identity = await buildIdentity();
397
+ let markConnected;
398
+ const connected = new Promise((resolve) => { markConnected = resolve; });
399
+ host = startRelayHost({
400
+ relayUrl: relay.wsUrl,
401
+ identity,
402
+ getLocalPort: () => 1,
403
+ onStatus: (status) => {
404
+ if (status.state === 'connected') markConnected();
405
+ },
406
+ logger: { warn: () => {}, info: () => {} },
407
+ });
408
+ await connected;
409
+ };
410
+
411
+ it('dials a waiting client again after a dial the relay refused', async () => {
412
+ await startHost(['reject', 'reject']);
413
+ relay.send({ type: 'connected', connectionId: 'conn-retry' });
414
+
415
+ // No second `connected` arrives: the host retries on its own.
416
+ expect(await relay.waitForDials(3, 6000)).toBe(true);
417
+ expect(relay.state.dials).toEqual(['conn-retry', 'conn-retry', 'conn-retry']);
418
+ await sleep(100);
419
+ expect(host.getStatus().connectedClients).toBe(1);
420
+ }, 10000);
421
+
422
+ it('gives up on a hung dial well inside the client handshake timeout and dials again', async () => {
423
+ await startHost(['hang']);
424
+ relay.send({ type: 'connected', connectionId: 'conn-hung' });
425
+
426
+ expect(await relay.waitForDials(2, 9000)).toBe(true);
427
+ await sleep(100);
428
+ expect(host.getStatus().connectedClients).toBe(1);
429
+ }, 12000);
430
+
431
+ it('stops retrying once the relay reports the client gone', async () => {
432
+ await startHost(['reject', 'reject', 'reject']);
433
+ relay.send({ type: 'connected', connectionId: 'conn-gone' });
434
+ expect(await relay.waitForDials(1, 2000)).toBe(true);
435
+ relay.send({ type: 'disconnected', connectionId: 'conn-gone' });
436
+
437
+ expect(await relay.waitForDials(2, 2500)).toBe(false);
438
+ expect(relay.state.dials).toEqual(['conn-gone']);
439
+ }, 10000);
440
+
441
+ it('leaves a data socket that opened and then closed to the client reconnect', async () => {
442
+ await startHost([]);
443
+ relay.send({ type: 'connected', connectionId: 'conn-opened' });
444
+ expect(await relay.waitForDials(1, 2000)).toBe(true);
445
+ await sleep(100);
446
+ relay.state.dataSockets[0].close(1012, 'gone');
447
+
448
+ expect(await relay.waitForDials(2, 2500)).toBe(false);
449
+ }, 10000);
450
+ });
451
+
452
+ // Bun's WebSocket fails a dial with an error whose message and `url` hold the
453
+ // whole dial URL, signed relay auth included. Node's `ws` never does, so these
454
+ // sockets stand in for Bun.
455
+ class BunLikeFailingSocket extends EventEmitter {
456
+ constructor(url) {
457
+ super();
458
+ this.url = url;
459
+ this.readyState = WebSocket.CONNECTING;
460
+ }
461
+
462
+ fail(reason) {
463
+ this.readyState = WebSocket.CLOSED;
464
+ this.emit('error', Object.assign(new Error(`WebSocket connection to '${this.url}' failed: ${reason}`), { url: this.url }));
465
+ this.emit('close', 1006, Buffer.alloc(0));
466
+ }
467
+
468
+ close() {}
469
+
470
+ terminate() {}
471
+
472
+ ping() {}
473
+ }
474
+
475
+ describe('relay host-client error redaction', () => {
476
+ const fakeIdentity = {
477
+ serverId: 'fake-server-id',
478
+ hostEncPrivateKey: null,
479
+ signRelayAuth: () => ({ ts: 1, sig: 'FAKE-SIGNATURE', pk: 'FAKE-PUBLIC-KEY' }),
480
+ };
481
+
482
+ it('keeps the signed relay auth out of logs and status when a dial fails', () => {
483
+ const sockets = [];
484
+ const warnings = [];
485
+ const host = startRelayHost({
486
+ relayUrl: 'wss://relay.example.test/ws',
487
+ identity: fakeIdentity,
488
+ getLocalPort: () => 1,
489
+ logger: { warn: (line) => warnings.push(line) },
490
+ createSocket: (url) => {
491
+ const socket = new BunLikeFailingSocket(url);
492
+ sockets.push(socket);
493
+ return socket;
494
+ },
495
+ });
496
+
497
+ try {
498
+ const control = sockets[0];
499
+ control.readyState = WebSocket.OPEN;
500
+ control.emit('open');
501
+ control.emit('message', Buffer.from(JSON.stringify({ type: 'connected', connectionId: 'conn-1' })), false);
502
+ expect(sockets[1].url).toContain('sig=FAKE-SIGNATURE');
503
+ sockets[1].fail('Expected 101 status code');
504
+ control.fail('Failed to connect');
505
+
506
+ const lastError = host.getStatus().lastError;
507
+ expect(warnings).toEqual(["[Relay] host-data socket error: WebSocket connection to 'wss://relay.example.test/ws' failed: Expected 101 status code"]);
508
+ expect(lastError).toBe("WebSocket connection to 'wss://relay.example.test/ws' failed: Failed to connect");
509
+ for (const text of [...warnings, lastError]) {
510
+ expect(text).not.toContain('FAKE-SIGNATURE');
511
+ expect(text).not.toContain('FAKE-PUBLIC-KEY');
512
+ }
513
+ } finally {
514
+ host.stop();
515
+ }
516
+ });
517
+ });
@@ -2,8 +2,7 @@
2
2
  // the push relay via signing-key.js — same storage, same serverId) plus a NEW
3
3
  // long-lived ECDH P-256 encryption keypair for the E2EE channel (WebCrypto
4
4
  // keys are single-purpose, so signing and encryption keys must differ).
5
- // The encryption keypair is persisted as `settings.relayEncryptionKey =
6
- // { privateJwk, publicJwk }`, mirroring the relaySigningKey precedent.
5
+ // Both keypairs are stored by key-store.js.
7
6
 
8
7
  import {
9
8
  canonicalPublicJwkString,
@@ -13,48 +12,26 @@ import {
13
12
  } from './signing-key.js';
14
13
  import { exportPublicKeyJwk, generateEcdhKeyPair, importEcdhPrivateKey } from './e2ee.js';
15
14
 
16
- const isJwkPair = (value) => Boolean(value && typeof value === 'object' && value.privateJwk && value.publicJwk);
17
-
18
15
  /**
19
16
  * @param {{
20
17
  * crypto: typeof import('node:crypto'),
21
- * readSettingsFromDiskMigrated: () => Promise<object>,
22
- * writeSettingsToDisk: (settings: object) => Promise<void>,
23
- * readSettingsStrict?: () => Promise<object>,
18
+ * relayKeyStore: ReturnType<typeof import('./key-store.js').createRelayKeyStore>,
24
19
  * }} deps
25
20
  */
26
21
  export const createRelayIdentityRuntime = (deps) => {
27
- const { crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict } = deps;
22
+ const { crypto, relayKeyStore } = deps;
28
23
 
29
24
  let cachedIdentity = null;
30
25
 
31
- const getOrCreateEncryptionKeypair = async () => {
32
- const settings = await readSettingsFromDiskMigrated();
33
- const existing = settings?.relayEncryptionKey;
34
- if (isJwkPair(existing)) {
35
- return existing;
36
- }
37
- // Same regeneration gate as the signing key: never mint a replacement
38
- // identity key off a swallowed read failure — a new encryption key breaks
39
- // the E2EE trust anchor pinned by every paired device. Verify "missing" via
40
- // the strict reader (throws on corrupt/unreadable) before generating.
41
- let verifiedSettings = settings;
42
- if (readSettingsStrict) {
43
- verifiedSettings = await readSettingsStrict();
44
- const verified = verifiedSettings?.relayEncryptionKey;
45
- if (isJwkPair(verified)) {
46
- return verified;
47
- }
48
- }
26
+ const getOrCreateEncryptionKeypair = () => relayKeyStore.getOrCreate('encryption', async () => {
49
27
  // Loud on purpose: a new encryption key invalidates the E2EE trust anchor of
50
28
  // every paired device. Expected exactly once, on first relay use.
51
29
  console.warn('[relay-identity] Generating NEW relay encryption keypair (E2EE trust anchor changes; previously paired devices must re-pair)');
52
30
  const keyPair = await generateEcdhKeyPair();
53
31
  const privateJwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.privateKey);
54
32
  const publicJwk = await exportPublicKeyJwk(keyPair.publicKey);
55
- await writeSettingsToDisk({ ...settings, ...(verifiedSettings || {}), relayEncryptionKey: { privateJwk, publicJwk } });
56
33
  return { privateJwk, publicJwk };
57
- };
34
+ });
58
35
 
59
36
  /**
60
37
  * @returns {Promise<{
@@ -66,7 +43,7 @@ export const createRelayIdentityRuntime = (deps) => {
66
43
  */
67
44
  const getRelayIdentity = async () => {
68
45
  if (cachedIdentity) return cachedIdentity;
69
- const signing = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
46
+ const signing = await getOrCreateRelaySigningKeypair({ crypto, relayKeyStore });
70
47
  const serverId = deriveServerId({ crypto }, signing.publicJwk);
71
48
  const encryption = await getOrCreateEncryptionKeypair();
72
49
  const hostEncPrivateKey = await importEcdhPrivateKey(encryption.privateJwk);