@vantran-se/openchamber-web 1.23.0 → 1.24.3

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 (362) hide show
  1. package/bin/cli.test.js +29 -6
  2. package/bin/lib/cli-http.js +5 -1
  3. package/bin/lib/commands-status.js +5 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-DFg2wnpo.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
  5. package/dist/assets/DiffView-COVQhWGd.js +4 -0
  6. package/dist/assets/FilesView-B1V5JxFX.js +13 -0
  7. package/dist/assets/GitView-CDOJy5Ay.js +6 -0
  8. package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
  9. package/dist/assets/{JsonTreeView-BKdsjg5B.js → JsonTreeView-D0C9nU7K.js} +1 -1
  10. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  12. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  30. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  33. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  39. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  42. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  54. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  57. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  60. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  62. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  65. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  68. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  69. package/dist/assets/LinearIssuesView-D7FKg1XJ.js +1 -0
  70. package/dist/assets/{MarkdownImageGallery-_5gEVr0X.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
  71. package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
  72. package/dist/assets/{OnboardingScreen-DdP4_nuc.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
  73. package/dist/assets/{PatchDiff-am3SAss2.js → PatchDiff-DnEhjXcC.js} +1 -1
  74. package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
  75. package/dist/assets/{PlanView-BoJxdRZe.js → PlanView-LW2z_EAP.js} +4 -4
  76. package/dist/assets/PluginPane-B4tKno2h.js +3 -0
  77. package/dist/assets/SettingsView-DEaMulN9.js +29 -0
  78. package/dist/assets/{SettingsWindow-DVTdDSol.js → SettingsWindow-FLDJXOgQ.js} +1 -1
  79. package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
  80. package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
  81. package/dist/assets/{ToolPartDiffPreview-C7WLk5i9.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
  82. package/dist/assets/{VirtualizedFileDiff-DcdgkH-x.js → VirtualizedFileDiff-0pyr_mHY.js} +4 -4
  83. package/dist/assets/WalkthroughView-yNj4nOP5.js +4 -0
  84. package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
  85. package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
  86. package/dist/assets/{computeVirtualFileMetrics-C8byMnRh.js → computeVirtualFileMetrics-DxTBMuu-.js} +2 -2
  87. package/dist/assets/de-DgxgFyGG.js +10 -0
  88. package/dist/assets/es-BFsZj5F_.js +16 -0
  89. package/dist/assets/fr-jdZ5KKQi.js +14 -0
  90. package/dist/assets/{index-BB7ryz8y.js → index-CKHjPKbq.js} +1 -1
  91. package/dist/assets/index-Cbci4i1W.js +40 -0
  92. package/dist/assets/index-DqaMWNrC.css +1 -0
  93. package/dist/assets/{isDiffPlainText-45ujMPGb.js → isDiffPlainText-D7FkBMP6.js} +1 -1
  94. package/dist/assets/{isFilePlainText-Cc4KFSYi.js → isFilePlainText-CL2vsrIZ.js} +1 -1
  95. package/dist/assets/ja-C5kKrhO4.js +16 -0
  96. package/dist/assets/ko-DXDTT_0O.js +16 -0
  97. package/dist/assets/main-CZALtVek.js +896 -0
  98. package/dist/assets/main-CgXMBNvG.js +2 -0
  99. package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
  100. package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
  101. package/dist/assets/miniChat-CwTmDixi.js +2 -0
  102. package/dist/assets/mobile-CLf2vCtO.js +2 -0
  103. package/dist/assets/patchFileDiff-Bsba8KFp.js +13 -0
  104. package/dist/assets/pl-D-7x4Y8Q.js +16 -0
  105. package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
  106. package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
  107. package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
  108. package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
  109. package/dist/assets/scheduledTasksApi-DzjbLd8P.js +1 -0
  110. package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
  111. package/dist/assets/surface-B6n7wJtt.js +1 -0
  112. package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
  113. package/dist/assets/tr-Dr-oJ22L.js +7 -0
  114. package/dist/assets/uk-9Nx83UcI.js +16 -0
  115. package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
  116. package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
  117. package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
  118. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-CsXYnR5c.js} +1 -1
  119. package/dist/assets/{vendor-codemirror-language-data-DzlSIu_W.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
  120. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
  121. package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
  122. package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
  123. package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
  124. package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
  125. package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
  126. package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
  127. package/dist/assets/vendor-zod-CgAdXWZO.js +39 -0
  128. package/dist/assets/{web-components-CdGfR2XJ.js → web-components-B8IcVQAA.js} +2 -2
  129. package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
  130. package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
  131. package/dist/index.html +6 -4
  132. package/dist/mini-chat.html +6 -4
  133. package/dist/mobile.html +6 -4
  134. package/package.json +12 -9
  135. package/server/built-in-extensions/registry.json +4 -0
  136. package/server/index.d.ts +2 -0
  137. package/server/index.js +47 -23
  138. package/server/lib/agent-memory/runtime.js +7 -2
  139. package/server/lib/agent-memory/runtime.test.js +22 -0
  140. package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
  141. package/server/lib/agent-tool/runtime.js +62 -15
  142. package/server/lib/agent-tool/runtime.test.js +88 -2
  143. package/server/lib/changelog/update-notes.js +2 -1
  144. package/server/lib/changelog/update-notes.test.js +19 -0
  145. package/server/lib/event-stream/DOCUMENTATION.md +26 -0
  146. package/server/lib/event-stream/delta-coalescer.js +0 -0
  147. package/server/lib/event-stream/delta-coalescer.test.js +292 -0
  148. package/server/lib/event-stream/global-hub.js +68 -13
  149. package/server/lib/event-stream/global-hub.test.js +182 -0
  150. package/server/lib/event-stream/global-ws-bridge.js +16 -18
  151. package/server/lib/event-stream/index.js +4 -0
  152. package/server/lib/event-stream/protocol.js +21 -7
  153. package/server/lib/event-stream/runtime.js +9 -6
  154. package/server/lib/event-stream/runtime.test.js +60 -0
  155. package/server/lib/fs/DOCUMENTATION.md +10 -1
  156. package/server/lib/fs/git-process.test.js +77 -0
  157. package/server/lib/fs/routes.js +58 -5
  158. package/server/lib/fs/routes.test.js +116 -0
  159. package/server/lib/fs/search.js +2 -1
  160. package/server/lib/git/DOCUMENTATION.md +30 -6
  161. package/server/lib/git/routes.js +37 -11
  162. package/server/lib/git/routes.test.js +78 -0
  163. package/server/lib/git/serial-refresh.js +93 -0
  164. package/server/lib/git/serial-refresh.test.js +112 -0
  165. package/server/lib/git/service.js +588 -74
  166. package/server/lib/git/service.test.js +492 -0
  167. package/server/lib/github/DOCUMENTATION.md +5 -0
  168. package/server/lib/github/pr-status.js +5 -5
  169. package/server/lib/github/pr-status.test.js +1 -1
  170. package/server/lib/github/routes.js +6 -6
  171. package/server/lib/guests/DOCUMENTATION.md +68 -0
  172. package/server/lib/guests/auth-store.js +184 -0
  173. package/server/lib/guests/auth-store.test.js +112 -0
  174. package/server/lib/guests/background.test.js +83 -0
  175. package/server/lib/guests/builtins.js +34 -0
  176. package/server/lib/guests/builtins.test.js +174 -0
  177. package/server/lib/guests/catalog.js +477 -0
  178. package/server/lib/guests/catalog.test.js +390 -0
  179. package/server/lib/guests/clone.js +297 -0
  180. package/server/lib/guests/extract-zip.js +136 -0
  181. package/server/lib/guests/extract-zip.test.js +109 -0
  182. package/server/lib/guests/files.js +308 -0
  183. package/server/lib/guests/files.test.js +262 -0
  184. package/server/lib/guests/grant-scope.js +81 -0
  185. package/server/lib/guests/grant-scope.test.js +75 -0
  186. package/server/lib/guests/host-session.js +73 -0
  187. package/server/lib/guests/host-session.test.js +30 -0
  188. package/server/lib/guests/html-styles.js +6 -0
  189. package/server/lib/guests/html-styles.test.js +45 -0
  190. package/server/lib/guests/html-tokens.js +73 -0
  191. package/server/lib/guests/html-tokens.test.js +53 -0
  192. package/server/lib/guests/install.js +406 -0
  193. package/server/lib/guests/install.test.js +338 -0
  194. package/server/lib/guests/oauth.js +501 -0
  195. package/server/lib/guests/oauth.test.js +481 -0
  196. package/server/lib/guests/persist.js +336 -0
  197. package/server/lib/guests/persist.test.js +288 -0
  198. package/server/lib/guests/request.js +112 -0
  199. package/server/lib/guests/request.test.js +206 -0
  200. package/server/lib/guests/routes.js +838 -0
  201. package/server/lib/guests/service.js +538 -0
  202. package/server/lib/guests/service.test.js +277 -0
  203. package/server/lib/guests/sockets.js +129 -0
  204. package/server/lib/guests/sockets.test.js +110 -0
  205. package/server/lib/guests/ssh-install.test.js +188 -0
  206. package/server/lib/guests/storage.js +68 -0
  207. package/server/lib/guests/storage.test.js +59 -0
  208. package/server/lib/guests/updates.js +294 -0
  209. package/server/lib/guests/updates.test.js +334 -0
  210. package/server/lib/guests/upload.js +50 -0
  211. package/server/lib/guests/upload.test.js +131 -0
  212. package/server/lib/linear/DOCUMENTATION.md +1 -1
  213. package/server/lib/message-queue/DOCUMENTATION.md +17 -1
  214. package/server/lib/message-queue/runtime.js +34 -1
  215. package/server/lib/message-queue/runtime.test.js +83 -6
  216. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  217. package/server/lib/notifications/emitter-runtime.js +4 -3
  218. package/server/lib/notifications/routes.js +5 -0
  219. package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
  220. package/server/lib/openchamber-sessions/routes.js +62 -29
  221. package/server/lib/openchamber-sessions/routes.test.js +96 -0
  222. package/server/lib/opencode/DOCUMENTATION.md +73 -5
  223. package/server/lib/opencode/bootstrap-runtime.js +1 -0
  224. package/server/lib/opencode/core-routes.js +39 -1
  225. package/server/lib/opencode/env-runtime.js +5 -1
  226. package/server/lib/opencode/env-runtime.test.js +72 -0
  227. package/server/lib/opencode/feature-routes-runtime.js +30 -1
  228. package/server/lib/opencode/lifecycle-process.test.js +84 -0
  229. package/server/lib/opencode/lifecycle.js +58 -70
  230. package/server/lib/opencode/lifecycle.test.js +29 -0
  231. package/server/lib/opencode/managed-plugin-config.js +1 -1
  232. package/server/lib/opencode/openchamber-routes.js +72 -14
  233. package/server/lib/opencode/openchamber-routes.test.js +71 -3
  234. package/server/lib/opencode/proxy.js +33 -0
  235. package/server/lib/opencode/session-runtime.js +68 -0
  236. package/server/lib/opencode/session-runtime.test.js +43 -0
  237. package/server/lib/opencode/settings-helpers.js +11 -0
  238. package/server/lib/opencode/settings-helpers.test.js +24 -2
  239. package/server/lib/opencode/settings-normalization-runtime.js +2 -0
  240. package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
  241. package/server/lib/opencode/settings-registry.json +18 -0
  242. package/server/lib/opencode/settings-runtime.js +36 -9
  243. package/server/lib/opencode/settings-runtime.test.js +158 -2
  244. package/server/lib/opencode/shutdown-runtime.js +3 -0
  245. package/server/lib/opencode/shutdown-runtime.test.js +24 -0
  246. package/server/lib/opencode/theme-archive.js +53 -0
  247. package/server/lib/opencode/theme-catalog.js +171 -0
  248. package/server/lib/opencode/theme-catalog.test.js +125 -0
  249. package/server/lib/opencode/theme-import.test.js +144 -0
  250. package/server/lib/opencode/theme-runtime.js +100 -28
  251. package/server/lib/opencode/theme-runtime.test.js +15 -0
  252. package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
  253. package/server/lib/permission-auto-accept/runtime.js +13 -0
  254. package/server/lib/permission-auto-accept/runtime.test.js +37 -1
  255. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  256. package/server/lib/project-context/runtime.js +8 -2
  257. package/server/lib/project-context/runtime.test.js +54 -0
  258. package/server/lib/projects/DOCUMENTATION.md +2 -1
  259. package/server/lib/projects/project-config.js +40 -11
  260. package/server/lib/projects/project-config.test.js +97 -0
  261. package/server/lib/projects/project-id.js +26 -0
  262. package/server/lib/quota/DOCUMENTATION.md +0 -1
  263. package/server/lib/quota/providers/index.js +0 -7
  264. package/server/lib/realtime-proxy-terminal.test.js +150 -0
  265. package/server/lib/realtime-proxy.js +2 -0
  266. package/server/lib/relay/DOCUMENTATION.md +20 -0
  267. package/server/lib/relay/host-client.js +7 -0
  268. package/server/lib/relay/host-client.test.js +14 -0
  269. package/server/lib/routing/DOCUMENTATION.md +83 -0
  270. package/server/lib/routing/defaults.js +98 -0
  271. package/server/lib/routing/feature-flag.js +11 -0
  272. package/server/lib/routing/history.js +34 -0
  273. package/server/lib/routing/jev.js +98 -0
  274. package/server/lib/routing/routes.http.test.js +100 -0
  275. package/server/lib/routing/routes.js +81 -0
  276. package/server/lib/routing/runtime.js +217 -0
  277. package/server/lib/routing/runtime.test.js +188 -0
  278. package/server/lib/routing/store.js +249 -0
  279. package/server/lib/routing/store.test.js +96 -0
  280. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  281. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  282. package/server/lib/scheduled-tasks/runtime.js +14 -4
  283. package/server/lib/scheduled-tasks/runtime.test.js +67 -0
  284. package/server/lib/security/request-security.js +9 -5
  285. package/server/lib/security/request-security.test.js +37 -0
  286. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  287. package/server/lib/session-assist/context.js +127 -0
  288. package/server/lib/session-assist/context.test.js +132 -0
  289. package/server/lib/session-assist/prompt.js +54 -0
  290. package/server/lib/session-assist/runtime.js +111 -257
  291. package/server/lib/session-assist/runtime.test.js +203 -0
  292. package/server/lib/small-model/DOCUMENTATION.md +14 -2
  293. package/server/lib/small-model/call.js +37 -6
  294. package/server/lib/small-model/call.test.js +182 -0
  295. package/server/lib/terminal/DOCUMENTATION.md +23 -2
  296. package/server/lib/terminal/runtime.js +27 -5
  297. package/server/lib/terminal/runtime.test.js +67 -0
  298. package/server/lib/terminal/shutdown.js +106 -0
  299. package/server/lib/terminal/shutdown.test.js +84 -0
  300. package/server/lib/tts/service.test.js +78 -0
  301. package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
  302. package/server/lib/ui-auth/session-cookie.js +51 -0
  303. package/server/lib/ui-auth/session-cookie.test.js +46 -0
  304. package/server/lib/ui-auth/ui-auth.js +56 -17
  305. package/server/lib/ui-auth/ui-auth.test.js +96 -0
  306. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  307. package/server/lib/walkthrough/pr-source.test.js +21 -0
  308. package/server/lib/walkthrough/pull-request.js +7 -4
  309. package/server/lib/walkthrough/pull-request.test.js +16 -0
  310. package/server/lib/walkthrough/routes.js +19 -0
  311. package/server/lib/walkthrough/routes.test.js +26 -0
  312. package/server/lib/walkthrough/sources.js +13 -2
  313. package/server/opencode-worktree-readiness.test.js +130 -0
  314. package/dist/assets/DiffView-3Dn4QHbu.js +0 -4
  315. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +0 -1
  316. package/dist/assets/FilesView-BdTOpew9.js +0 -17
  317. package/dist/assets/GitView-Dqf9vHJh.js +0 -6
  318. package/dist/assets/LinearIssuesView-CmbeL7I6.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +0 -12
  320. package/dist/assets/PierreDiffViewer-DCsydlKs.js +0 -161
  321. package/dist/assets/SettingsView-CcM8pF5O.js +0 -28
  322. package/dist/assets/ToolOutputDialog-BgfTbDZ4.js +0 -15
  323. package/dist/assets/WalkthroughView-BEVUDxuw.js +0 -4
  324. package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
  325. package/dist/assets/de-C8jV7J9y.js +0 -10
  326. package/dist/assets/es-BI6J1M5R.js +0 -16
  327. package/dist/assets/fr-D6oDFt7E.js +0 -14
  328. package/dist/assets/index-BqhiCmYS.js +0 -39
  329. package/dist/assets/index-FqSOBz0J.css +0 -1
  330. package/dist/assets/ja-C_4i5EQu.js +0 -16
  331. package/dist/assets/ko-BFbOTpbq.js +0 -16
  332. package/dist/assets/main-C9ETLKFc.js +0 -2
  333. package/dist/assets/main-CJmb2dF8.js +0 -930
  334. package/dist/assets/markdownCore-DVuZpnli.js +0 -5
  335. package/dist/assets/miniChat-B4Ytoq2n.js +0 -2
  336. package/dist/assets/mobile-BUT6-lo1.js +0 -2
  337. package/dist/assets/patchFileDiff-8j1apICU.js +0 -13
  338. package/dist/assets/pl-COFvT56l.js +0 -16
  339. package/dist/assets/pt-BR-DtVOO2Lj.js +0 -16
  340. package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
  341. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +0 -2
  342. package/dist/assets/renderMobileApp-BNgHraq6.js +0 -25
  343. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +0 -17
  344. package/dist/assets/scheduledTasksApi-GC4epIYA.js +0 -1
  345. package/dist/assets/shikiHighlight-BJISeZsQ.js +0 -1
  346. package/dist/assets/surface-BvRzS58U.js +0 -1
  347. package/dist/assets/tr-B8Q0Go2l.js +0 -7
  348. package/dist/assets/uk-Dk4a-Fn-.js +0 -16
  349. package/dist/assets/useAppFontEffects-DpFNefUA.js +0 -7284
  350. package/dist/assets/useGitComparison-Dfxaqd0s.js +0 -1
  351. package/dist/assets/useInlineCommentController-CscsE4t-.js +0 -2
  352. package/dist/assets/useRootScrollLock-BaCvuRlT.js +0 -1
  353. package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
  354. package/dist/assets/vendor-zod-BPAANqvH.js +0 -39
  355. package/dist/assets/zh-CN-B_BXxG1Q.js +0 -16
  356. package/dist/assets/zh-TW-DLrl1SPZ.js +0 -16
  357. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  358. package/server/lib/mcp-reconnect/runtime.js +0 -122
  359. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
  360. package/server/lib/quota/providers/crof.js +0 -96
  361. package/server/lib/quota/providers/crof.test.js +0 -98
  362. /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
6
7
  import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
7
8
 
8
9
  const PROJECT_ID = 'path_dGVzdA';
@@ -200,6 +201,59 @@ describe('legacy migration', () => {
200
201
  });
201
202
  });
202
203
 
204
+ describe('long project ids', () => {
205
+ // A checkout nested deep enough that its `path_<base64url>` id is longer
206
+ // than a file or folder name may be; the storage lands under the bounded
207
+ // stem instead of failing with ENAMETOOLONG.
208
+ const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
209
+ const stem = projectConfigFileStemOf(longProjectId);
210
+
211
+ test('the id maps to a bounded folder name', () => {
212
+ expect(longProjectId.length).toBeGreaterThan(240);
213
+ expect(stem.startsWith('path_sha256_')).toBe(true);
214
+ expect(stem.length).toBeLessThan(100);
215
+ expect(runtime.contextPathFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'context.json'));
216
+ expect(runtime.plansDirFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'plans'));
217
+ // A short id keeps naming its folder itself.
218
+ expect(runtime.contextPathFor(PROJECT_ID)).toBe(contextPath());
219
+ });
220
+
221
+ test('a missing context reads as empty instead of failing on the legacy config lookup', async () => {
222
+ expect(await runtime.readContext(longProjectId)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
223
+ });
224
+
225
+ test('context and plans round-trip through the bounded folder', async () => {
226
+ await runtime.saveTodos(longProjectId, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
227
+ const { note } = await runtime.createNote(longProjectId, { body: 'kept' });
228
+ const { plan } = await runtime.createPlan(longProjectId, { title: 'Deep plan', body: 'step one' });
229
+
230
+ const context = await runtime.readContext(longProjectId);
231
+ expect(context.todos).toEqual([{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
232
+ expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
233
+ expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
234
+ expect((await runtime.readPlan(longProjectId, plan.id)).body).toBe('step one');
235
+
236
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).todos).toHaveLength(1);
237
+ await fsPromises.access(path.join(projectsDirPath, stem, 'plans', plan.file));
238
+ expect(await fsPromises.readdir(projectsDirPath)).toEqual([stem]);
239
+ });
240
+
241
+ test('legacy keys are migrated out of the bounded config file', async () => {
242
+ const boundedConfigPath = path.join(projectsDirPath, `${stem}.json`);
243
+ await writeJson(boundedConfigPath, {
244
+ 'setup-worktree': ['bun install'],
245
+ projectNotes: 'legacy notes',
246
+ projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
247
+ });
248
+
249
+ const context = await runtime.readContext(longProjectId);
250
+ expect(context.notes.map((entry) => entry.body)).toEqual(['legacy notes']);
251
+ expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
252
+ expect(await readJson(boundedConfigPath)).toEqual({ 'setup-worktree': ['bun install'] });
253
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).notes[0].body).toBe('legacy notes');
254
+ });
255
+ });
256
+
203
257
  describe('todos', () => {
204
258
  test('round-trips through disk', async () => {
205
259
  await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
@@ -63,7 +63,7 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
63
63
 
64
64
  ## Modules
65
65
 
66
- - `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
66
+ - `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree. `projectConfigFileStemOf`: the stem that names the file and the sibling folder for an id, see the file name invariant below.
67
67
  - `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
68
68
  - `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no OpenChamber server; keep the two in sync.
69
69
  - `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
@@ -72,5 +72,6 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
72
72
 
73
73
  - **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
74
74
  - **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
75
+ - **The file name is bounded, and so is the folder beside it.** The file is `<projectId>.json` and the per-project folder is `<projectId>/` while the id is at most 200 characters. A `path_<base64url>` id grows with the checkout path, so a deeply nested project (a path of roughly 150 characters or more) would otherwise get a name beyond the 255-byte limit and every write, lock, and temp file would fail with ENAMETOOLONG. Such an id is stored as `path_sha256_<hex digest of the id>.json` instead, with the folder `path_sha256_<digest>/` beside it (`projectConfigFileStemOf`; every composer of either path goes through it: `project-config.js`, `project-context/runtime.js`, `agent-memory/runtime.js`, and the id migration and orphan recovery in `opencode/settings-runtime.js`). The digest keeps the `path_` prefix so orphan recovery skips it. A file an older build managed to write under the long name is still read when the bounded file is missing and is moved to the bounded name by the next write; a malformed one is a read failure, not an empty project. A folder an older build created under the raw id (possible only for ids of 201 to 255 characters; longer names never got one) is moved into the bounded folder once at startup, by the project id migration in `opencode/settings-runtime.js`, with `context.json` merged by entry identity when both exist. The VS Code extension host mirrors both the naming rule and the legacy read-then-move for the file (`bridge-project-setup-runtime.ts`), because a VS Code-only user has no server to do it; it never touches the folder.
75
76
  - **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
76
77
  - **`OPENCHAMBER_DATA_DIR` moves this directory too.** Every OpenChamber folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
@@ -1,7 +1,7 @@
1
1
  import { DateTime, IANAZone } from 'luxon';
2
- import parser from 'cron-parser';
2
+ import { CronExpressionParser } from 'cron-parser';
3
3
 
4
- import { projectPathFromId } from './project-id.js';
4
+ import { projectConfigFileStemOf, projectPathFromId } from './project-id.js';
5
5
  import {
6
6
  DEFAULT_PLANS_DIR,
7
7
  EMPTY_SHARED_PROJECT_CONFIG,
@@ -145,7 +145,7 @@ const normalizeTimezone = (value, fallback = resolveDefaultTimezone()) => {
145
145
 
146
146
  const validateCronExpression = (expression, timezone) => {
147
147
  try {
148
- const iterator = parser.parseExpression(expression, {
148
+ const iterator = CronExpressionParser.parse(expression, {
149
149
  tz: timezone,
150
150
  currentDate: new Date(),
151
151
  });
@@ -399,6 +399,15 @@ export const createProjectConfigRuntime = (deps) => {
399
399
 
400
400
  const resolveProjectConfigPath = (projectID) => {
401
401
  const safeProjectID = sanitizeProjectID(projectID);
402
+ return path.join(projectsDirPath, `${projectConfigFileStemOf(safeProjectID)}.json`);
403
+ };
404
+
405
+ // Where a build before the bounded file name stored an id too long for one
406
+ // (on a filesystem that still accepted it), or null when the id's own name
407
+ // is the current one. Read as a fallback, moved on the next write.
408
+ const resolveLegacyProjectConfigPath = (projectID) => {
409
+ const safeProjectID = sanitizeProjectID(projectID);
410
+ if (projectConfigFileStemOf(safeProjectID) === safeProjectID) return null;
402
411
  return path.join(projectsDirPath, `${safeProjectID}.json`);
403
412
  };
404
413
 
@@ -500,18 +509,31 @@ export const createProjectConfigRuntime = (deps) => {
500
509
  throw new Error(`timeout acquiring project config lock for ${projectID}`);
501
510
  };
502
511
 
503
- const readRawProjectConfigFromDisk = async (projectID) => {
504
- const filePath = resolveProjectConfigPath(projectID);
512
+ // The parsed document, or null when there is no file (one of
513
+ // `missingCodes`). Malformed JSON still throws; it is a broken file, not
514
+ // an empty one.
515
+ const readJsonDocumentIfPresent = async (filePath, missingCodes) => {
516
+ let raw;
505
517
  try {
506
- const raw = await fsPromises.readFile(filePath, 'utf8');
507
- const parsed = JSON.parse(raw);
508
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
518
+ raw = await fsPromises.readFile(filePath, 'utf8');
509
519
  } catch (error) {
510
- if (error && typeof error === 'object' && error.code === 'ENOENT') {
511
- return {};
512
- }
520
+ const code = error && typeof error === 'object' ? error.code : null;
521
+ if (missingCodes.includes(code)) return null;
513
522
  throw error;
514
523
  }
524
+ const parsed = JSON.parse(raw);
525
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
526
+ };
527
+
528
+ // The legacy name of a long id may exceed what the filesystem can hold;
529
+ // ENAMETOOLONG there means "no such file". On the bounded path it would
530
+ // mean the projects dir itself is unusable, so it stays an error.
531
+ const readRawProjectConfigFromDisk = async (projectID) => {
532
+ const current = await readJsonDocumentIfPresent(resolveProjectConfigPath(projectID), ['ENOENT']);
533
+ if (current) return current;
534
+ const legacyPath = resolveLegacyProjectConfigPath(projectID);
535
+ const legacy = legacyPath ? await readJsonDocumentIfPresent(legacyPath, ['ENOENT', 'ENAMETOOLONG']) : null;
536
+ return legacy ?? {};
515
537
  };
516
538
 
517
539
  // Normalized tasks for reading, plus the raw on-disk record of each one for
@@ -578,6 +600,13 @@ export const createProjectConfigRuntime = (deps) => {
578
600
  await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
579
601
  throw error;
580
602
  }
603
+ // Every write goes through a read of the whole document, so the legacy
604
+ // file's content is now in the current one; drop it so a later read (or an
605
+ // older build) cannot see two copies drifting apart.
606
+ const legacyPath = resolveLegacyProjectConfigPath(projectID);
607
+ if (legacyPath) {
608
+ await fsPromises.rm(legacyPath, { force: true }).catch(() => {});
609
+ }
581
610
  };
582
611
 
583
612
  const writeProjectConfigToDisk = async (projectID, config) => {
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
5
5
  import { createProjectConfigRuntime } from './project-config.js';
6
+ import { createProjectIdFromPath } from './project-id.js';
6
7
 
7
8
  const createRuntime = async () => {
8
9
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
@@ -175,6 +176,102 @@ describe('project-config runtime', () => {
175
176
  });
176
177
  });
177
178
 
179
+ describe('project-config file naming', () => {
180
+ const nightly = {
181
+ name: 'nightly',
182
+ enabled: true,
183
+ schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
184
+ execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
185
+ };
186
+
187
+ it('names the file by the id while the id fits a file name', async () => {
188
+ const { runtime, tempRoot, cleanup } = await createRuntime();
189
+ try {
190
+ const projectID = createProjectIdFromPath('/Users/someone/projects/demo');
191
+ expect(runtime.resolveProjectConfigPath(projectID)).toBe(path.join(tempRoot, `${projectID}.json`));
192
+ } finally {
193
+ await cleanup();
194
+ }
195
+ });
196
+
197
+ it('stores a project whose path is too long for a file name under a bounded name', async () => {
198
+ const { runtime, tempRoot, cleanup } = await createRuntime();
199
+ try {
200
+ // ~190 characters, like a scratchpad checkout nested under a worktree;
201
+ // the base64url id is longer than the 255-byte file name limit allows.
202
+ const projectPath = `/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`;
203
+ expect(projectPath.length).toBeGreaterThan(180);
204
+ const projectID = createProjectIdFromPath(projectPath);
205
+ expect(projectID.length).toBeGreaterThan(240);
206
+
207
+ const filePath = runtime.resolveProjectConfigPath(projectID);
208
+ const fileName = path.basename(filePath);
209
+ expect(path.dirname(filePath)).toBe(tempRoot);
210
+ expect(fileName.startsWith('path_sha256_')).toBe(true);
211
+ expect(fileName.length).toBeLessThan(100);
212
+ // The same id always maps to the same file.
213
+ expect(runtime.resolveProjectConfigPath(projectID)).toBe(filePath);
214
+
215
+ // The whole write path (lock file, temp file, rename) works on a real filesystem.
216
+ const created = await runtime.upsertScheduledTask(projectID, nightly);
217
+ expect(created.created).toBe(true);
218
+ const setup = await runtime.updateProjectSetup(projectID, { setupWorktree: ['bun install'] });
219
+ expect(setup.setupWorktree).toEqual(['bun install']);
220
+
221
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
222
+ expect(raw.scheduledTasks).toHaveLength(1);
223
+ expect(raw['setup-worktree']).toEqual(['bun install']);
224
+ expect(await runtime.listScheduledTasks(projectID)).toHaveLength(1);
225
+ expect((await runtime.readProjectSetup(projectID)).setupWorktree).toEqual(['bun install']);
226
+ } finally {
227
+ await cleanup();
228
+ }
229
+ });
230
+
231
+ it('reads a long id from its pre-bound file name and moves it on the next write', async () => {
232
+ const { runtime, tempRoot, cleanup } = await createRuntime();
233
+ try {
234
+ // Long enough for the bounded name, short enough that an older build
235
+ // could still have written `<id>.json` on this filesystem.
236
+ const projectID = `path_${'a'.repeat(200)}`;
237
+ const legacyPath = path.join(tempRoot, `${projectID}.json`);
238
+ const currentPath = runtime.resolveProjectConfigPath(projectID);
239
+ expect(currentPath).not.toBe(legacyPath);
240
+ await writeFile(legacyPath, JSON.stringify({
241
+ version: 1,
242
+ scheduledTasks: [{ id: 'task-legacy', ...nightly, state: { createdAt: 1, updatedAt: 2, lastStatus: 'idle' } }],
243
+ projectNotes: 'keep me',
244
+ }), 'utf8');
245
+
246
+ const tasks = await runtime.listScheduledTasks(projectID);
247
+ expect(tasks.map((task) => task.id)).toEqual(['task-legacy']);
248
+
249
+ await runtime.upsertScheduledTask(projectID, { ...nightly, name: 'weekly digest' });
250
+
251
+ const raw = JSON.parse(await readFile(currentPath, 'utf8'));
252
+ expect(raw.scheduledTasks.map((task) => task.id)).toEqual(['task-legacy', 'task-fixed-id']);
253
+ expect(raw.projectNotes).toBe('keep me');
254
+ await expect(readFile(legacyPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
255
+ expect((await runtime.listScheduledTasks(projectID)).map((task) => task.name)).toEqual(['nightly', 'weekly digest']);
256
+ } finally {
257
+ await cleanup();
258
+ }
259
+ });
260
+
261
+ it('treats a malformed pre-bound file as a failure, not as an empty project', async () => {
262
+ const { runtime, tempRoot, cleanup } = await createRuntime();
263
+ try {
264
+ const projectID = `path_${'b'.repeat(200)}`;
265
+ await writeFile(path.join(tempRoot, `${projectID}.json`), '{ not json', 'utf8');
266
+ await expect(runtime.listScheduledTasks(projectID)).rejects.toThrow();
267
+ await expect(runtime.upsertScheduledTask(projectID, nightly)).rejects.toThrow();
268
+ await expect(readFile(runtime.resolveProjectConfigPath(projectID), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
269
+ } finally {
270
+ await cleanup();
271
+ }
272
+ });
273
+ });
274
+
178
275
  describe('project-config loop reconciliation', () => {
179
276
  const loop = (name, overrides = {}) => ({
180
277
  scope: 'project',
@@ -1,3 +1,11 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ // The longest stem `<stem>.json` may have and still leave room, inside the
4
+ // 255-byte file name limit, for the atomic-write `.tmp-<pid>-<ms>-<random>`
5
+ // suffix and the `.json.lock` sibling. Ids are ASCII, so characters are bytes.
6
+ const MAX_PROJECT_CONFIG_FILE_STEM_LENGTH = 200;
7
+ const HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX = 'path_sha256_';
8
+
1
9
  const normalizeProjectPathForId = (value) => {
2
10
  if (typeof value !== 'string') return '';
3
11
  return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
@@ -28,3 +36,21 @@ export const projectPathFromId = (projectId) => {
28
36
  return '';
29
37
  }
30
38
  };
39
+
40
+ /**
41
+ * The stem of everything a project owns in the projects dir: the per-user
42
+ * config file `<projectsDir>/<stem>.json` and the sibling folder
43
+ * `<projectsDir>/<stem>/` (context, plans, memory). It is the id itself while
44
+ * that fits a file name. A `path_<base64url>` id grows with the checkout path,
45
+ * so a deeply nested project would otherwise get a name the filesystem
46
+ * rejects (ENAMETOOLONG); such an id maps to a fixed-length digest instead.
47
+ * The digest keeps the `path_` prefix so the orphan recovery in
48
+ * `opencode/settings-runtime.js` never mistakes the file for a leftover of the
49
+ * random-id era. The VS Code extension host applies the same rule
50
+ * (`packages/vscode/src/bridge-project-setup-runtime.ts`); keep the two in sync.
51
+ */
52
+ export const projectConfigFileStemOf = (projectId) => {
53
+ if (projectId.length <= MAX_PROJECT_CONFIG_FILE_STEM_LENGTH) return projectId;
54
+ const digest = crypto.createHash('sha256').update(projectId, 'utf8').digest('hex');
55
+ return `${HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX}${digest}`;
56
+ };
@@ -27,7 +27,6 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
27
27
  | `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
28
28
  | `command-code` | Command Code | `providers/command-code.js` | `command-code` OAuth/API credential in OpenCode `auth.json`, or `COMMAND_CODE_API_KEY` |
29
29
  | `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
30
- | `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
31
30
  | `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
32
31
  | `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
33
32
  | `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
@@ -11,7 +11,6 @@ import * as claude from './claude/index.js';
11
11
  import * as clinePass from './cline-pass.js';
12
12
  import * as codex from './codex.js';
13
13
  import * as copilot from './copilot.js';
14
- import * as crof from './crof.js';
15
14
  import * as cursor from './cursor.js';
16
15
  import * as deepseek from './deepseek.js';
17
16
  import * as exeDev from './exe-dev.js';
@@ -50,12 +49,6 @@ const registry = {
50
49
  isConfigured: codex.isConfigured,
51
50
  fetchQuota: codex.fetchQuota
52
51
  },
53
- crof: {
54
- providerId: crof.providerId,
55
- providerName: crof.providerName,
56
- isConfigured: crof.isConfigured,
57
- fetchQuota: crof.fetchQuota
58
- },
59
52
  cursor: {
60
53
  providerId: cursor.providerId,
61
54
  providerName: cursor.providerName,
@@ -0,0 +1,150 @@
1
+ import { afterAll, expect, it } from 'vitest';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import express from 'express';
8
+ import { WebSocket } from 'ws';
9
+
10
+ import { attachRealtimeProxy, buildRealtimeProxyWsUrl } from './realtime-proxy.js';
11
+ import { createRequestSecurityRuntime } from './security/request-security.js';
12
+ import { createTerminalRuntime } from './terminal/runtime.js';
13
+ import { readTerminalWsControlFrame } from './terminal/terminal-ws-protocol.js';
14
+
15
+ const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
16
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-proxy-terminal-'));
17
+ process.env.OPENCHAMBER_DATA_DIR = dataDir;
18
+ const { createUiAuth } = await import('./ui-auth/ui-auth.js');
19
+
20
+ afterAll(() => {
21
+ if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
22
+ else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
23
+ fs.rmSync(dataDir, { recursive: true, force: true });
24
+ });
25
+
26
+ const listen = async (server) => {
27
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
28
+ return `http://127.0.0.1:${server.address().port}`;
29
+ };
30
+
31
+ const login = async (origin, password) => {
32
+ const response = await fetch(`${origin}/auth/session`, {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ password }),
36
+ });
37
+ expect(response.status).toBe(200);
38
+ const cookie = response.headers.get('set-cookie').split(';')[0];
39
+ const minted = await fetch(`${origin}/auth/url-token`, {
40
+ method: 'POST',
41
+ headers: { Cookie: cookie, 'Content-Type': 'application/json' },
42
+ body: '{}',
43
+ });
44
+ expect(minted.status).toBe(200);
45
+ const { token } = await minted.json();
46
+ expect(token).toBeTruthy();
47
+ return token;
48
+ };
49
+
50
+ const connect = (url, origin = 'openchamber-ui://app') => new Promise((resolve, reject) => {
51
+ const socket = new WebSocket(url, { origin });
52
+ let hello = null;
53
+ let status = null;
54
+ const timeout = setTimeout(() => {
55
+ socket.terminate();
56
+ reject(new Error('Timed out waiting for terminal handshake'));
57
+ }, 3000);
58
+ socket.on('upgrade', (response) => { status = response.statusCode; });
59
+ socket.on('unexpected-response', (_request, response) => {
60
+ status = response.statusCode;
61
+ response.resume();
62
+ socket.terminate();
63
+ });
64
+ socket.on('message', (raw) => {
65
+ hello = readTerminalWsControlFrame(raw);
66
+ socket.close();
67
+ });
68
+ socket.on('error', () => {});
69
+ socket.on('close', (code) => {
70
+ clearTimeout(timeout);
71
+ resolve({ hello, status, code });
72
+ });
73
+ });
74
+
75
+ it('connects through the desktop proxy to an authenticated terminal without weakening either gate', async () => {
76
+ const password = crypto.randomBytes(24).toString('hex');
77
+ const remoteAuth = createUiAuth({ password });
78
+ const localAuth = createUiAuth({ password });
79
+ const remoteApp = express();
80
+ const localApp = express();
81
+ for (const [app, auth] of [[remoteApp, remoteAuth], [localApp, localAuth]]) {
82
+ app.use(express.json());
83
+ app.post('/auth/session', auth.handleSessionCreate);
84
+ app.post('/auth/url-token', auth.handleUrlAuthToken);
85
+ }
86
+ const remote = http.createServer(remoteApp);
87
+ const local = http.createServer(localApp);
88
+ const security = createRequestSecurityRuntime({ readSettingsFromDiskMigrated: async () => ({}) });
89
+ const received = [];
90
+ remote.on('upgrade', (req) => {
91
+ received.push({ origin: req.headers.origin, custom: req.headers['x-proxy-test'] });
92
+ });
93
+ const rejected = [];
94
+ const terminal = createTerminalRuntime({
95
+ app: remoteApp, server: remote, fs, path, uiAuthController: remoteAuth,
96
+ buildAugmentedPath: () => process.env.PATH || '',
97
+ searchPathFor: () => null,
98
+ isExecutable: () => false,
99
+ isRequestOriginAllowed: security.isRequestOriginAllowed,
100
+ rejectWebSocketUpgrade: (socket, status, reason) => {
101
+ rejected.push(status);
102
+ security.rejectWebSocketUpgrade(socket, status, reason);
103
+ },
104
+ TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000,
105
+ });
106
+ let proxy;
107
+ try {
108
+ const remoteOrigin = await listen(remote);
109
+ const localOrigin = await listen(local);
110
+ proxy = attachRealtimeProxy({
111
+ app: localApp, server: local,
112
+ getDesktopRuntimeConfig: () => ({
113
+ apiBaseUrl: remoteOrigin,
114
+ requestHeaders: { 'X-Proxy-Test': 'present' },
115
+ }),
116
+ getUiAuthController: () => localAuth,
117
+ isRequestOriginAllowed: security.isRequestOriginAllowed,
118
+ });
119
+ const remoteToken = await login(remoteOrigin, password);
120
+ const localToken = await login(localOrigin, password);
121
+ const target = new URL('/api/terminal/ws', remoteOrigin);
122
+ target.protocol = 'ws:';
123
+ target.searchParams.set('oc_url_token', remoteToken);
124
+ const proxied = new URL(buildRealtimeProxyWsUrl(localOrigin, target.toString()));
125
+ proxied.searchParams.set('oc_url_token', localToken);
126
+
127
+ expect((await connect(target.toString())).hello).toEqual({ t: 'hello', v: 3 });
128
+ expect((await connect(proxied.toString())).hello).toEqual({ t: 'hello', v: 3 });
129
+ expect(received.at(-1)).toEqual({ origin: remoteOrigin, custom: 'present' });
130
+ expect(rejected).toEqual([]);
131
+
132
+ const beforeRejectedLocal = received.length;
133
+ expect((await connect(proxied.toString(), 'https://untrusted.example')).status).toBe(403);
134
+ const unauthenticatedLocal = new URL(proxied);
135
+ unauthenticatedLocal.searchParams.delete('oc_url_token');
136
+ expect((await connect(unauthenticatedLocal.toString())).status).toBe(401);
137
+ expect(received).toHaveLength(beforeRejectedLocal);
138
+
139
+ target.searchParams.delete('oc_url_token');
140
+ proxied.searchParams.set('url', target.toString());
141
+ expect((await connect(proxied.toString())).hello).toBeNull();
142
+ expect(rejected).toEqual([401]);
143
+ } finally {
144
+ proxy?.stop();
145
+ await terminal.shutdown();
146
+ remoteAuth.dispose();
147
+ localAuth.dispose();
148
+ await Promise.all([remote, local].map((server) => new Promise((resolve) => server.close(resolve))));
149
+ }
150
+ });
@@ -200,6 +200,8 @@ export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getU
200
200
 
201
201
  const upstream = new WebSocket(resolved.target.toString(), {
202
202
  headers: resolved.requestHeaders,
203
+ // ws does not send Origin by default; protected upstreams require it.
204
+ origin: `${resolved.target.protocol === 'wss:' ? 'https:' : 'http:'}//${resolved.target.host}`,
203
205
  });
204
206
  const pending = [];
205
207
 
@@ -97,6 +97,26 @@ It does not replace a physical iOS/LTE check.
97
97
 
98
98
  ## Authentication model
99
99
 
100
+ ### Self-reported app diagnostics
101
+
102
+ Host-control, host-data and client upgrades include optional `appId`,
103
+ `appVersion` and `platform` query parameters. OpenChamber sends `openchamber`,
104
+ the local package version and the local runtime. Client platforms use the
105
+ existing presence values: `desktop` for Electron, `vscode`, `ios`, `android`
106
+ and `web` for browsers, including hosted mobile. Hosts use
107
+ `OPENCHAMBER_RUNTIME`, defaulting to `web`, so SSH hosts report `ssh-remote`.
108
+ These labels describe the connecting process, not its remote target.
109
+
110
+ These fields are diagnostics only, outside the signed auth payload. Older
111
+ relays ignore them and older apps remain connectable. The hosted relay groups
112
+ missing or invalid fields as `unknown`. Its analytics count host-control and
113
+ client connections separately, including reconnects, and show labels as
114
+ self-reported, never as verified app identity. Host-data sockets are excluded.
115
+ App diagnostics are written on connection, without per-app traffic counters.
116
+ No credentials, device names or application contents are added to diagnostics.
117
+
118
+ ### Access checks
119
+
100
120
  - The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
101
121
  - Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
102
122
  - The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
@@ -4,6 +4,7 @@
4
4
  // dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
5
5
 
6
6
  import { WebSocket } from 'ws';
7
+ import { createRequire } from 'node:module';
7
8
 
8
9
  import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
9
10
  import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
@@ -50,6 +51,8 @@ const resolveBatchWindowMs = (option) => {
50
51
  * }} options
51
52
  */
52
53
  export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
54
+ const { version } = createRequire(import.meta.url)('../../../package.json');
55
+ const platform = process.env.OPENCHAMBER_RUNTIME || 'web';
53
56
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
54
57
  const localBatch = batch !== false;
55
58
  const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
@@ -82,6 +85,10 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
82
85
  url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
83
86
  url.searchParams.set('role', role);
84
87
  url.searchParams.set('serverId', identity.serverId);
88
+ // Self-reported diagnostics, not part of relay authentication.
89
+ url.searchParams.set('appId', 'openchamber');
90
+ url.searchParams.set('appVersion', version);
91
+ url.searchParams.set('platform', platform);
85
92
  if (connectionId) url.searchParams.set('connectionId', connectionId);
86
93
  const auth = identity.signRelayAuth(role, connectionId ?? null);
87
94
  url.searchParams.set('ts', String(auth.ts));
@@ -39,11 +39,17 @@ const startFakeRelay = () => {
39
39
  clients: new Map(), // connectionId -> ws
40
40
  buffered: new Map(), // connectionId -> [[data, isBinary]] awaiting host-data
41
41
  relayFrames: [], // observed forwarded frames (for plaintext assertions)
42
+ apps: new Map(),
42
43
  };
43
44
 
44
45
  wss.on('connection', (ws, req) => {
45
46
  const url = new URL(req.url, 'http://localhost');
46
47
  const role = url.searchParams.get('role');
48
+ state.apps.set(role, {
49
+ appId: url.searchParams.get('appId'),
50
+ appVersion: url.searchParams.get('appVersion'),
51
+ platform: url.searchParams.get('platform'),
52
+ });
47
53
  const connectionId = url.searchParams.get('connectionId');
48
54
 
49
55
  if (role === 'host-control') {
@@ -101,6 +107,7 @@ const startFakeRelay = () => {
101
107
  // A socket a failed test left open would hold server.close() until
102
108
  // the hook timeout; drop them so a failure is reported once.
103
109
  for (const client of wss.clients) client.terminate();
110
+ server.closeAllConnections();
104
111
  wss.close();
105
112
  server.close(() => r());
106
113
  }),
@@ -286,6 +293,13 @@ describe('relay host-client integration', () => {
286
293
  });
287
294
 
288
295
  expect(result.status).toBe(200);
296
+ for (const role of ['host-control', 'host-data']) {
297
+ expect(relay.state.apps.get(role)).toEqual({
298
+ appId: 'openchamber',
299
+ appVersion: expect.stringMatching(/^\d+\.\d+\.\d+/),
300
+ platform: process.env.OPENCHAMBER_RUNTIME || 'web',
301
+ });
302
+ }
289
303
  expect(result.body.ok).toBe(true);
290
304
  expect(result.body.relayConn).toBe('conn-test-1');
291
305
  expect(result.body.origin).toBe(`http://127.0.0.1:${origin.port}`);