@thevinci/web 1.0.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 (337) hide show
  1. package/README.md +197 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +4887 -0
  5. package/bin/cli.test.js +64 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +528 -0
  12. package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
  13. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  14. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  15. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  19. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  20. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  21. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  25. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  26. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  27. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  32. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  33. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  34. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  35. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  37. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  38. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  39. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  40. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  42. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  43. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  44. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  45. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  52. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  53. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  54. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  55. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  56. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  57. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  58. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  59. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  60. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  61. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  62. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  63. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  64. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  65. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  66. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  67. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  68. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  69. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  70. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  71. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  72. package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
  73. package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
  74. package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
  75. package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
  76. package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
  77. package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
  78. package/dist/assets/es-4o9ciP61.js +15 -0
  79. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  80. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  81. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  82. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  83. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  84. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  85. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  86. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  87. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  88. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  89. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  90. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  91. package/dist/assets/index-DLTDToSP.css +1 -0
  92. package/dist/assets/index-DgiFEKGN.js +1 -0
  93. package/dist/assets/ko-B20imCHE.js +15 -0
  94. package/dist/assets/main-BV3KOtdA.css +1 -0
  95. package/dist/assets/main-CDKJj0sH.js +226 -0
  96. package/dist/assets/main-LC-PSNVM.js +2 -0
  97. package/dist/assets/miniChat-CQUiG_cr.js +2 -0
  98. package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
  99. package/dist/assets/pl-DQJ7LSzj.js +15 -0
  100. package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
  101. package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
  102. package/dist/assets/uk-BNFxOlO4.js +15 -0
  103. package/dist/assets/vendor--DBfsbEis.css +1 -0
  104. package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
  105. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  106. package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
  107. package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
  108. package/dist/assets/worker-CbT6TVo7.js +155 -0
  109. package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
  110. package/dist/favicon-16.png +0 -0
  111. package/dist/favicon-32.png +0 -0
  112. package/dist/favicon.png +0 -0
  113. package/dist/favicon.svg +528 -0
  114. package/dist/index.html +607 -0
  115. package/dist/logo-dark-192x192.png +0 -0
  116. package/dist/logo-dark-512x512.png +0 -0
  117. package/dist/logo-dark-512x512.svg +528 -0
  118. package/dist/logo-light-192x192.png +0 -0
  119. package/dist/logo-light-512x512.png +0 -0
  120. package/dist/logo-light-512x512.svg +528 -0
  121. package/dist/mini-chat.html +16 -0
  122. package/dist/pwa-192.png +0 -0
  123. package/dist/pwa-512.png +0 -0
  124. package/dist/pwa-maskable-192.png +0 -0
  125. package/dist/pwa-maskable-512.png +0 -0
  126. package/dist/site.webmanifest +21 -0
  127. package/dist/sw.js +1 -0
  128. package/package.json +118 -0
  129. package/public/apple-touch-icon-120x120.png +0 -0
  130. package/public/apple-touch-icon-152x152.png +0 -0
  131. package/public/apple-touch-icon-167x167.png +0 -0
  132. package/public/apple-touch-icon-180x180.png +0 -0
  133. package/public/apple-touch-icon.png +0 -0
  134. package/public/apple-touch-icon.svg +528 -0
  135. package/public/favicon-16.png +0 -0
  136. package/public/favicon-32.png +0 -0
  137. package/public/favicon.png +0 -0
  138. package/public/favicon.svg +528 -0
  139. package/public/logo-dark-192x192.png +0 -0
  140. package/public/logo-dark-512x512.png +0 -0
  141. package/public/logo-dark-512x512.svg +528 -0
  142. package/public/logo-light-192x192.png +0 -0
  143. package/public/logo-light-512x512.png +0 -0
  144. package/public/logo-light-512x512.svg +528 -0
  145. package/public/pwa-192.png +0 -0
  146. package/public/pwa-512.png +0 -0
  147. package/public/pwa-maskable-192.png +0 -0
  148. package/public/pwa-maskable-512.png +0 -0
  149. package/public/site.webmanifest +21 -0
  150. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  151. package/server/index.d.ts +39 -0
  152. package/server/index.js +1311 -0
  153. package/server/lib/cloudflare-tunnel.js +650 -0
  154. package/server/lib/event-stream/DOCUMENTATION.md +61 -0
  155. package/server/lib/event-stream/directory-ws-bridge.js +185 -0
  156. package/server/lib/event-stream/global-hub.js +158 -0
  157. package/server/lib/event-stream/global-hub.test.js +140 -0
  158. package/server/lib/event-stream/global-ws-bridge.js +206 -0
  159. package/server/lib/event-stream/index.js +25 -0
  160. package/server/lib/event-stream/protocol.js +131 -0
  161. package/server/lib/event-stream/protocol.test.js +182 -0
  162. package/server/lib/event-stream/runtime.js +180 -0
  163. package/server/lib/event-stream/runtime.test.js +512 -0
  164. package/server/lib/event-stream/upstream-reader.js +226 -0
  165. package/server/lib/event-stream/upstream-reader.test.js +276 -0
  166. package/server/lib/fs/DOCUMENTATION.md +36 -0
  167. package/server/lib/fs/routes.js +1040 -0
  168. package/server/lib/fs/search.js +238 -0
  169. package/server/lib/git/DOCUMENTATION.md +152 -0
  170. package/server/lib/git/credentials.js +74 -0
  171. package/server/lib/git/identity-storage.js +112 -0
  172. package/server/lib/git/index.js +6 -0
  173. package/server/lib/git/routes.js +972 -0
  174. package/server/lib/git/service.js +3432 -0
  175. package/server/lib/git/service.test.js +39 -0
  176. package/server/lib/github/DOCUMENTATION.md +171 -0
  177. package/server/lib/github/auth.js +307 -0
  178. package/server/lib/github/device-flow.js +50 -0
  179. package/server/lib/github/index.js +24 -0
  180. package/server/lib/github/octokit.js +10 -0
  181. package/server/lib/github/pr-status.js +519 -0
  182. package/server/lib/github/repo/fork-detection.js +102 -0
  183. package/server/lib/github/repo/index.js +55 -0
  184. package/server/lib/github/routes.js +1560 -0
  185. package/server/lib/magic-prompts/routes.js +63 -0
  186. package/server/lib/magic-prompts/runtime.js +119 -0
  187. package/server/lib/notifications/DOCUMENTATION.md +122 -0
  188. package/server/lib/notifications/emitter-runtime.js +102 -0
  189. package/server/lib/notifications/index.js +4 -0
  190. package/server/lib/notifications/message.js +52 -0
  191. package/server/lib/notifications/message.test.js +34 -0
  192. package/server/lib/notifications/push-runtime.js +304 -0
  193. package/server/lib/notifications/routes.js +315 -0
  194. package/server/lib/notifications/runtime.js +566 -0
  195. package/server/lib/notifications/template-runtime.js +349 -0
  196. package/server/lib/notifications/template-runtime.test.js +26 -0
  197. package/server/lib/opencode/DOCUMENTATION.md +362 -0
  198. package/server/lib/opencode/agents.js +634 -0
  199. package/server/lib/opencode/auth-state-runtime.js +88 -0
  200. package/server/lib/opencode/auth.js +83 -0
  201. package/server/lib/opencode/bootstrap-runtime.js +131 -0
  202. package/server/lib/opencode/cli-entry-runtime.js +43 -0
  203. package/server/lib/opencode/cli-options.js +128 -0
  204. package/server/lib/opencode/commands.js +339 -0
  205. package/server/lib/opencode/config-entity-routes.js +370 -0
  206. package/server/lib/opencode/core-routes.js +500 -0
  207. package/server/lib/opencode/core-routes.test.js +26 -0
  208. package/server/lib/opencode/env-config.js +74 -0
  209. package/server/lib/opencode/env-keys.js +68 -0
  210. package/server/lib/opencode/env-runtime.js +1162 -0
  211. package/server/lib/opencode/env-runtime.test.js +116 -0
  212. package/server/lib/opencode/feature-routes-runtime.js +244 -0
  213. package/server/lib/opencode/hmr-state-runtime.js +85 -0
  214. package/server/lib/opencode/index.js +66 -0
  215. package/server/lib/opencode/lifecycle.js +1019 -0
  216. package/server/lib/opencode/lifecycle.test.js +240 -0
  217. package/server/lib/opencode/mcp.js +278 -0
  218. package/server/lib/opencode/network-runtime.js +104 -0
  219. package/server/lib/opencode/network-runtime.test.js +37 -0
  220. package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
  221. package/server/lib/opencode/path-utils.js +100 -0
  222. package/server/lib/opencode/path-utils.test.js +71 -0
  223. package/server/lib/opencode/project-directory-runtime.js +124 -0
  224. package/server/lib/opencode/project-icon-routes.js +399 -0
  225. package/server/lib/opencode/project-icon-routes.test.js +107 -0
  226. package/server/lib/opencode/providers.js +96 -0
  227. package/server/lib/opencode/proxy.js +445 -0
  228. package/server/lib/opencode/pwa-manifest-routes.js +257 -0
  229. package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
  230. package/server/lib/opencode/routes.js +541 -0
  231. package/server/lib/opencode/server-startup-runtime.js +156 -0
  232. package/server/lib/opencode/server-utils-runtime.js +168 -0
  233. package/server/lib/opencode/server-utils-runtime.test.js +135 -0
  234. package/server/lib/opencode/session-runtime.js +356 -0
  235. package/server/lib/opencode/session-runtime.test.js +151 -0
  236. package/server/lib/opencode/settings-helpers.js +770 -0
  237. package/server/lib/opencode/settings-helpers.test.js +109 -0
  238. package/server/lib/opencode/settings-normalization-runtime.js +428 -0
  239. package/server/lib/opencode/settings-runtime.js +826 -0
  240. package/server/lib/opencode/settings-runtime.test.js +85 -0
  241. package/server/lib/opencode/shared.js +615 -0
  242. package/server/lib/opencode/shutdown-runtime.js +139 -0
  243. package/server/lib/opencode/shutdown-runtime.test.js +58 -0
  244. package/server/lib/opencode/skill-routes.js +701 -0
  245. package/server/lib/opencode/skills.js +548 -0
  246. package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
  247. package/server/lib/opencode/static-routes-runtime.js +65 -0
  248. package/server/lib/opencode/theme-runtime.js +167 -0
  249. package/server/lib/opencode/tunnel-auth.js +591 -0
  250. package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
  251. package/server/lib/opencode/vinci-routes.js +76 -0
  252. package/server/lib/opencode/watcher.js +115 -0
  253. package/server/lib/opencode/watcher.test.js +239 -0
  254. package/server/lib/preview/proxy-runtime.js +1333 -0
  255. package/server/lib/preview/proxy-runtime.test.js +144 -0
  256. package/server/lib/projects/project-config.js +567 -0
  257. package/server/lib/projects/project-config.test.js +175 -0
  258. package/server/lib/projects/project-id.js +13 -0
  259. package/server/lib/quota/DOCUMENTATION.md +58 -0
  260. package/server/lib/quota/index.js +25 -0
  261. package/server/lib/quota/providers/claude.js +107 -0
  262. package/server/lib/quota/providers/codex.js +113 -0
  263. package/server/lib/quota/providers/copilot.js +165 -0
  264. package/server/lib/quota/providers/google/api.js +92 -0
  265. package/server/lib/quota/providers/google/auth.js +108 -0
  266. package/server/lib/quota/providers/google/index.js +124 -0
  267. package/server/lib/quota/providers/google/transforms.js +109 -0
  268. package/server/lib/quota/providers/index.js +168 -0
  269. package/server/lib/quota/providers/interface.js +55 -0
  270. package/server/lib/quota/providers/kimi.js +108 -0
  271. package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
  272. package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
  273. package/server/lib/quota/providers/nanogpt.js +124 -0
  274. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  275. package/server/lib/quota/providers/openai.js +91 -0
  276. package/server/lib/quota/providers/openrouter.js +92 -0
  277. package/server/lib/quota/providers/zai.js +91 -0
  278. package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
  279. package/server/lib/quota/providers/zhipuai.js +114 -0
  280. package/server/lib/quota/routes.js +27 -0
  281. package/server/lib/quota/utils/auth.js +50 -0
  282. package/server/lib/quota/utils/formatters.js +85 -0
  283. package/server/lib/quota/utils/formatters.test.js +54 -0
  284. package/server/lib/quota/utils/index.js +10 -0
  285. package/server/lib/quota/utils/transformers.js +55 -0
  286. package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
  287. package/server/lib/scheduled-tasks/routes.js +235 -0
  288. package/server/lib/scheduled-tasks/runtime.js +773 -0
  289. package/server/lib/scheduled-tasks/runtime.test.js +100 -0
  290. package/server/lib/security/request-security.js +115 -0
  291. package/server/lib/session-folders/routes.js +63 -0
  292. package/server/lib/session-folders/routes.test.js +102 -0
  293. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  294. package/server/lib/skills-catalog/cache.js +29 -0
  295. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  296. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  297. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  298. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  299. package/server/lib/skills-catalog/curated-sources.js +21 -0
  300. package/server/lib/skills-catalog/git.js +77 -0
  301. package/server/lib/skills-catalog/index.js +42 -0
  302. package/server/lib/skills-catalog/install.js +294 -0
  303. package/server/lib/skills-catalog/scan.js +221 -0
  304. package/server/lib/skills-catalog/source.js +87 -0
  305. package/server/lib/terminal/DOCUMENTATION.md +76 -0
  306. package/server/lib/terminal/index.js +31 -0
  307. package/server/lib/terminal/output-replay-buffer.js +78 -0
  308. package/server/lib/terminal/output-replay-buffer.test.js +75 -0
  309. package/server/lib/terminal/runtime.js +850 -0
  310. package/server/lib/terminal/runtime.test.js +96 -0
  311. package/server/lib/terminal/terminal-ws-protocol.js +68 -0
  312. package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
  313. package/server/lib/text/DOCUMENTATION.md +35 -0
  314. package/server/lib/text/summarization.js +138 -0
  315. package/server/lib/text/summarization.test.js +34 -0
  316. package/server/lib/tts/DOCUMENTATION.md +146 -0
  317. package/server/lib/tts/base-url.js +62 -0
  318. package/server/lib/tts/capability-runtime.js +31 -0
  319. package/server/lib/tts/index.js +19 -0
  320. package/server/lib/tts/routes.js +261 -0
  321. package/server/lib/tts/routes.test.js +53 -0
  322. package/server/lib/tts/service.js +178 -0
  323. package/server/lib/tts/stt.js +75 -0
  324. package/server/lib/tunnels/DOCUMENTATION.md +18 -0
  325. package/server/lib/tunnels/index.js +166 -0
  326. package/server/lib/tunnels/managed-config.js +201 -0
  327. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  328. package/server/lib/tunnels/registry.js +51 -0
  329. package/server/lib/tunnels/routes.js +605 -0
  330. package/server/lib/tunnels/types.js +219 -0
  331. package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
  332. package/server/lib/ui-auth/ui-auth.js +673 -0
  333. package/server/lib/ui-auth/ui-passkeys.js +545 -0
  334. package/server/opencode-proxy.test.js +151 -0
  335. package/server/proxy-headers.js +61 -0
  336. package/server/proxy-headers.test.js +58 -0
  337. package/server/sse-routes.test.js +152 -0
@@ -0,0 +1,545 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ generateAuthenticationOptions,
7
+ generateRegistrationOptions,
8
+ verifyAuthenticationResponse,
9
+ verifyRegistrationResponse,
10
+ } from '@simplewebauthn/server';
11
+
12
+ const DEFAULT_STORE_VERSION = 1;
13
+ const DEFAULT_CHALLENGE_TTL_MS = 5 * 60 * 1000;
14
+ const DEFAULT_RP_NAME = 'Vinci';
15
+
16
+ const VINCI_DATA_DIR = process.env.VINCI_DATA_DIR
17
+ ? path.resolve(process.env.VINCI_DATA_DIR)
18
+ : path.join(os.homedir(), '.vinci');
19
+
20
+ const PASSKEY_STORE_FILE = path.join(VINCI_DATA_DIR, 'ui-passkeys.json');
21
+
22
+ const createUserId = () => crypto.randomBytes(32).toString('base64url');
23
+
24
+ const decodeUserId = (value) => {
25
+ if (typeof value !== 'string' || !value) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ return Uint8Array.from(Buffer.from(value, 'base64url'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ };
35
+
36
+ const normalizeLabel = (value, fallback) => {
37
+ if (typeof value !== 'string') {
38
+ return fallback;
39
+ }
40
+ const normalized = value.trim().replace(/\s+/g, ' ');
41
+ return normalized ? normalized.slice(0, 120) : fallback;
42
+ };
43
+
44
+ const normalizeHost = (value) => {
45
+ if (typeof value !== 'string') {
46
+ return '';
47
+ }
48
+
49
+ const trimmed = value.trim();
50
+ if (!trimmed) {
51
+ return '';
52
+ }
53
+
54
+ if (trimmed.startsWith('[')) {
55
+ const end = trimmed.indexOf(']');
56
+ return end >= 0 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
57
+ }
58
+
59
+ const colonIndex = trimmed.indexOf(':');
60
+ return (colonIndex >= 0 ? trimmed.slice(0, colonIndex) : trimmed).toLowerCase();
61
+ };
62
+
63
+ const isLocalRpId = (rpID) => rpID === 'localhost' || rpID === '127.0.0.1' || rpID === '::1';
64
+
65
+ const getCurrentRequestOrigin = (req) => {
66
+ const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
67
+ ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
68
+ : '';
69
+ const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
70
+ const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
71
+ ? req.headers['x-forwarded-host'].split(',')[0].trim()
72
+ : '';
73
+ const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
74
+
75
+ if (!host) {
76
+ return '';
77
+ }
78
+
79
+ return `${protocol}://${host}`;
80
+ };
81
+
82
+ const getCurrentRpId = (req) => {
83
+ const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
84
+ ? req.headers['x-forwarded-host'].split(',')[0].trim()
85
+ : '';
86
+ const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
87
+ return normalizeHost(host || req.hostname || '');
88
+ };
89
+
90
+ const parseStoredPasskey = (record) => {
91
+ if (!record || typeof record !== 'object') {
92
+ return null;
93
+ }
94
+
95
+ if (typeof record.id !== 'string' || typeof record.publicKey !== 'string' || typeof record.rpID !== 'string') {
96
+ return null;
97
+ }
98
+
99
+ return {
100
+ id: record.id,
101
+ publicKey: record.publicKey,
102
+ counter: typeof record.counter === 'number' && Number.isFinite(record.counter) ? record.counter : 0,
103
+ transports: Array.isArray(record.transports)
104
+ ? record.transports.filter((value) => typeof value === 'string')
105
+ : [],
106
+ deviceType: typeof record.deviceType === 'string' ? record.deviceType : 'singleDevice',
107
+ backedUp: record.backedUp === true,
108
+ createdAt: typeof record.createdAt === 'number' ? record.createdAt : Date.now(),
109
+ lastUsedAt: typeof record.lastUsedAt === 'number' ? record.lastUsedAt : null,
110
+ label: normalizeLabel(record.label, 'Unnamed device'),
111
+ rpID: record.rpID,
112
+ };
113
+ };
114
+
115
+ export const createUiPasskeys = ({
116
+ passwordBinding,
117
+ readSettingsFromDiskMigrated,
118
+ storeFile = PASSKEY_STORE_FILE,
119
+ rpName = DEFAULT_RP_NAME,
120
+ challengeTtlMs = DEFAULT_CHALLENGE_TTL_MS,
121
+ } = {}) => {
122
+ const registrationChallenges = new Map();
123
+ const authenticationChallenges = new Map();
124
+
125
+ const ensureStoreDirectory = () => {
126
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true });
127
+ };
128
+
129
+ const persistStore = (store) => {
130
+ ensureStoreDirectory();
131
+ fs.writeFileSync(storeFile, JSON.stringify(store, null, 2));
132
+ };
133
+
134
+ const createEmptyStore = () => ({
135
+ version: DEFAULT_STORE_VERSION,
136
+ userID: createUserId(),
137
+ passwordBinding,
138
+ passkeys: [],
139
+ });
140
+
141
+ const loadStore = () => {
142
+ let store = createEmptyStore();
143
+
144
+ try {
145
+ if (fs.existsSync(storeFile)) {
146
+ const raw = fs.readFileSync(storeFile, 'utf8');
147
+ const parsed = JSON.parse(raw);
148
+ store = {
149
+ version: DEFAULT_STORE_VERSION,
150
+ userID: decodeUserId(parsed?.userID) ? parsed.userID : store.userID,
151
+ passwordBinding: typeof parsed?.passwordBinding === 'string' ? parsed.passwordBinding : '',
152
+ passkeys: Array.isArray(parsed?.passkeys) ? parsed.passkeys.map(parseStoredPasskey).filter(Boolean) : [],
153
+ };
154
+ }
155
+ } catch (error) {
156
+ console.warn('[UI Passkeys] Failed to read passkey store:', error?.message || error);
157
+ }
158
+
159
+ if (!passwordBinding) {
160
+ if (store.passkeys.length > 0 || store.passwordBinding) {
161
+ store = { ...store, passkeys: [], passwordBinding: '' };
162
+ persistStore(store);
163
+ }
164
+ return store;
165
+ }
166
+
167
+ if (store.passwordBinding !== passwordBinding) {
168
+ store = {
169
+ version: DEFAULT_STORE_VERSION,
170
+ userID: store.userID || createUserId(),
171
+ passwordBinding,
172
+ passkeys: [],
173
+ };
174
+ persistStore(store);
175
+ return store;
176
+ }
177
+
178
+ if (!fs.existsSync(storeFile)) {
179
+ persistStore(store);
180
+ }
181
+
182
+ return store;
183
+ };
184
+
185
+ const cleanupChallengeMap = (map) => {
186
+ const now = Date.now();
187
+ for (const [requestId, record] of map.entries()) {
188
+ if (!record || now >= record.expiresAt) {
189
+ map.delete(requestId);
190
+ }
191
+ }
192
+ };
193
+
194
+ const buildOriginCandidates = async (req) => {
195
+ const origins = new Set();
196
+ const currentOrigin = getCurrentRequestOrigin(req);
197
+ if (currentOrigin) {
198
+ origins.add(currentOrigin);
199
+ }
200
+
201
+ try {
202
+ const settings = await readSettingsFromDiskMigrated?.();
203
+ if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
204
+ origins.add(new URL(settings.publicOrigin.trim()).origin);
205
+ }
206
+ } catch {
207
+ }
208
+
209
+ return Array.from(origins);
210
+ };
211
+
212
+ const assertEnabled = () => {
213
+ if (!passwordBinding) {
214
+ const error = new Error('Passkeys require UI password protection to be enabled');
215
+ error.statusCode = 400;
216
+ throw error;
217
+ }
218
+ };
219
+
220
+ const getPasskeysForRpId = (store, rpID) => store.passkeys.filter((passkey) => passkey.rpID === rpID);
221
+
222
+ const getStatus = (req) => {
223
+ const store = loadStore();
224
+ const rpID = getCurrentRpId(req);
225
+ return {
226
+ enabled: Boolean(passwordBinding),
227
+ hasPasskeys: Boolean(rpID) && getPasskeysForRpId(store, rpID).length > 0,
228
+ passkeyCount: Boolean(rpID) ? getPasskeysForRpId(store, rpID).length : 0,
229
+ rpID,
230
+ };
231
+ };
232
+
233
+ const listPasskeys = (req) => {
234
+ assertEnabled();
235
+
236
+ const store = loadStore();
237
+ const rpID = getCurrentRpId(req);
238
+ if (!rpID) {
239
+ return [];
240
+ }
241
+
242
+ return getPasskeysForRpId(store, rpID).map((passkey) => ({
243
+ id: passkey.id,
244
+ label: passkey.label,
245
+ createdAt: passkey.createdAt,
246
+ lastUsedAt: passkey.lastUsedAt,
247
+ deviceType: passkey.deviceType,
248
+ backedUp: passkey.backedUp,
249
+ }));
250
+ };
251
+
252
+ const revokePasskey = (req, passkeyId) => {
253
+ assertEnabled();
254
+
255
+ const normalizedPasskeyId = typeof passkeyId === 'string' ? passkeyId.trim() : '';
256
+ if (!normalizedPasskeyId) {
257
+ const error = new Error('Passkey ID is required');
258
+ error.statusCode = 400;
259
+ throw error;
260
+ }
261
+
262
+ const store = loadStore();
263
+ const rpID = getCurrentRpId(req);
264
+ const existingPasskey = store.passkeys.find((passkey) => passkey.id === normalizedPasskeyId && passkey.rpID === rpID);
265
+
266
+ if (!existingPasskey) {
267
+ const error = new Error('Passkey not found for this host');
268
+ error.statusCode = 404;
269
+ throw error;
270
+ }
271
+
272
+ const nextPasskeys = store.passkeys.filter((passkey) => !(passkey.id === normalizedPasskeyId && passkey.rpID === rpID));
273
+ persistStore({
274
+ ...store,
275
+ passwordBinding,
276
+ passkeys: nextPasskeys,
277
+ });
278
+
279
+ return {
280
+ revoked: true,
281
+ passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === rpID).length,
282
+ };
283
+ };
284
+
285
+ const clearAllPasskeys = () => {
286
+ assertEnabled();
287
+
288
+ const store = loadStore();
289
+ const clearedCount = store.passkeys.length;
290
+ persistStore({
291
+ ...store,
292
+ userID: crypto.randomBytes(32).toString('base64url'),
293
+ passwordBinding,
294
+ passkeys: [],
295
+ });
296
+
297
+ return {
298
+ cleared: true,
299
+ clearedCount,
300
+ };
301
+ };
302
+
303
+ const beginRegistration = async (req, { label } = {}) => {
304
+ assertEnabled();
305
+ cleanupChallengeMap(registrationChallenges);
306
+
307
+ const rpID = getCurrentRpId(req);
308
+ if (!rpID) {
309
+ const error = new Error('Unable to resolve a valid passkey host for this request');
310
+ error.statusCode = 400;
311
+ throw error;
312
+ }
313
+
314
+ const currentOrigin = getCurrentRequestOrigin(req);
315
+ if (!currentOrigin) {
316
+ const error = new Error('Unable to resolve a valid passkey origin for this request');
317
+ error.statusCode = 400;
318
+ throw error;
319
+ }
320
+
321
+ const store = loadStore();
322
+ const userID = decodeUserId(store.userID);
323
+ if (!userID) {
324
+ const error = new Error('Passkey storage is invalid. Please try again.');
325
+ error.statusCode = 500;
326
+ throw error;
327
+ }
328
+
329
+ const options = await generateRegistrationOptions({
330
+ rpName,
331
+ rpID,
332
+ userID,
333
+ userName: 'vinci-ui',
334
+ userDisplayName: 'Vinci UI',
335
+ attestationType: 'none',
336
+ excludeCredentials: getPasskeysForRpId(store, rpID).map((passkey) => ({
337
+ id: passkey.id,
338
+ transports: passkey.transports,
339
+ })),
340
+ authenticatorSelection: {
341
+ residentKey: 'required',
342
+ userVerification: 'required',
343
+ },
344
+ });
345
+
346
+ const requestId = crypto.randomBytes(16).toString('base64url');
347
+ registrationChallenges.set(requestId, {
348
+ challenge: options.challenge,
349
+ expectedOrigins: await buildOriginCandidates(req),
350
+ expectedRPIDs: [rpID],
351
+ rpID,
352
+ label: normalizeLabel(label, 'This device'),
353
+ createdAt: Date.now(),
354
+ expiresAt: Date.now() + challengeTtlMs,
355
+ });
356
+
357
+ return {
358
+ requestId,
359
+ optionsJSON: options,
360
+ };
361
+ };
362
+
363
+ const finishRegistration = async (payload) => {
364
+ assertEnabled();
365
+ cleanupChallengeMap(registrationChallenges);
366
+
367
+ const store = loadStore();
368
+ const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
369
+ const response = payload?.response;
370
+
371
+ const matchingRecord = requestId ? registrationChallenges.get(requestId) : null;
372
+ if (!matchingRecord) {
373
+ const error = new Error('Passkey setup has expired. Please try again.');
374
+ error.statusCode = 400;
375
+ throw error;
376
+ }
377
+
378
+ registrationChallenges.delete(requestId);
379
+
380
+ const verification = await verifyRegistrationResponse({
381
+ response,
382
+ expectedChallenge: matchingRecord.challenge,
383
+ expectedOrigin: matchingRecord.expectedOrigins,
384
+ expectedRPID: matchingRecord.expectedRPIDs,
385
+ requireUserVerification: true,
386
+ });
387
+
388
+ if (!verification.verified || !verification.registrationInfo) {
389
+ const error = new Error('Passkey registration could not be verified');
390
+ error.statusCode = 400;
391
+ throw error;
392
+ }
393
+
394
+ const {
395
+ credential,
396
+ credentialDeviceType,
397
+ credentialBackedUp,
398
+ } = verification.registrationInfo;
399
+
400
+ const nextPasskeys = store.passkeys.filter((passkey) => passkey.id !== credential.id);
401
+ nextPasskeys.push({
402
+ id: credential.id,
403
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
404
+ counter: credential.counter,
405
+ transports: Array.isArray(credential.transports) ? credential.transports.filter((value) => typeof value === 'string') : [],
406
+ deviceType: credentialDeviceType,
407
+ backedUp: credentialBackedUp,
408
+ createdAt: Date.now(),
409
+ lastUsedAt: null,
410
+ label: matchingRecord.label,
411
+ rpID: matchingRecord.rpID,
412
+ });
413
+
414
+ persistStore({
415
+ ...store,
416
+ passwordBinding,
417
+ passkeys: nextPasskeys,
418
+ });
419
+
420
+ return {
421
+ verified: true,
422
+ passkeyCount: nextPasskeys.filter((passkey) => passkey.rpID === matchingRecord.rpID).length,
423
+ };
424
+ };
425
+
426
+ const beginAuthentication = async (req) => {
427
+ assertEnabled();
428
+ cleanupChallengeMap(authenticationChallenges);
429
+
430
+ const store = loadStore();
431
+ const rpID = getCurrentRpId(req);
432
+ const passkeys = getPasskeysForRpId(store, rpID);
433
+
434
+ if (!rpID || passkeys.length === 0) {
435
+ const error = new Error('No passkeys are registered for this host yet');
436
+ error.statusCode = 404;
437
+ throw error;
438
+ }
439
+
440
+ const options = await generateAuthenticationOptions({
441
+ rpID,
442
+ userVerification: 'required',
443
+ allowCredentials: passkeys.map((passkey) => ({
444
+ id: passkey.id,
445
+ transports: passkey.transports,
446
+ })),
447
+ });
448
+
449
+ const requestId = crypto.randomBytes(16).toString('base64url');
450
+ authenticationChallenges.set(requestId, {
451
+ challenge: options.challenge,
452
+ expectedOrigins: await buildOriginCandidates(req),
453
+ expectedRPIDs: [rpID],
454
+ createdAt: Date.now(),
455
+ expiresAt: Date.now() + challengeTtlMs,
456
+ });
457
+
458
+ return {
459
+ requestId,
460
+ optionsJSON: options,
461
+ };
462
+ };
463
+
464
+ const finishAuthentication = async (payload) => {
465
+ assertEnabled();
466
+ cleanupChallengeMap(authenticationChallenges);
467
+
468
+ const requestId = typeof payload?.requestId === 'string' ? payload.requestId : '';
469
+ const response = payload?.response;
470
+ const store = loadStore();
471
+ const passkey = store.passkeys.find((item) => item.id === response?.id);
472
+
473
+ if (!passkey) {
474
+ const error = new Error('That passkey is not registered for this Vinci instance');
475
+ error.statusCode = 404;
476
+ throw error;
477
+ }
478
+
479
+ const matchingRecord = requestId ? authenticationChallenges.get(requestId) : null;
480
+ if (!matchingRecord) {
481
+ const error = new Error('Passkey sign-in has expired. Please try again.');
482
+ error.statusCode = 400;
483
+ throw error;
484
+ }
485
+
486
+ authenticationChallenges.delete(requestId);
487
+
488
+ const verification = await verifyAuthenticationResponse({
489
+ response,
490
+ expectedChallenge: matchingRecord.challenge,
491
+ expectedOrigin: matchingRecord.expectedOrigins,
492
+ expectedRPID: matchingRecord.expectedRPIDs,
493
+ credential: {
494
+ id: passkey.id,
495
+ publicKey: Buffer.from(passkey.publicKey, 'base64url'),
496
+ counter: passkey.counter,
497
+ transports: passkey.transports,
498
+ },
499
+ requireUserVerification: true,
500
+ });
501
+
502
+ if (!verification.verified || !verification.authenticationInfo) {
503
+ const error = new Error('Passkey sign-in could not be verified');
504
+ error.statusCode = 400;
505
+ throw error;
506
+ }
507
+
508
+ const nextPasskeys = store.passkeys.map((item) => (
509
+ item.id === passkey.id
510
+ ? {
511
+ ...item,
512
+ counter: verification.authenticationInfo.newCounter,
513
+ lastUsedAt: Date.now(),
514
+ }
515
+ : item
516
+ ));
517
+
518
+ persistStore({
519
+ ...store,
520
+ passwordBinding,
521
+ passkeys: nextPasskeys,
522
+ });
523
+
524
+ return { verified: true };
525
+ };
526
+
527
+ const dispose = () => {
528
+ registrationChallenges.clear();
529
+ authenticationChallenges.clear();
530
+ };
531
+
532
+ return {
533
+ enabled: Boolean(passwordBinding),
534
+ getStatus,
535
+ listPasskeys,
536
+ revokePasskey,
537
+ clearAllPasskeys,
538
+ beginRegistration,
539
+ finishRegistration,
540
+ beginAuthentication,
541
+ finishAuthentication,
542
+ dispose,
543
+ isLocalRpId,
544
+ };
545
+ };
@@ -0,0 +1,151 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import express from 'express';
4
+ import path from 'path';
5
+
6
+ import { createSseBoundaryTracker, registerOpenCodeProxy, writeSseChunkWithBackpressure } from './lib/opencode/proxy.js';
7
+
8
+ const listen = (app, host = '127.0.0.1') => new Promise((resolve, reject) => {
9
+ const server = app.listen(0, host, () => resolve(server));
10
+ server.once('error', reject);
11
+ });
12
+
13
+ const closeServer = (server) => new Promise((resolve, reject) => {
14
+ if (!server) {
15
+ resolve();
16
+ return;
17
+ }
18
+ server.close((error) => {
19
+ if (error) {
20
+ reject(error);
21
+ return;
22
+ }
23
+ resolve();
24
+ });
25
+ });
26
+
27
+ describe('OpenCode proxy SSE forwarding', () => {
28
+ let upstreamServer;
29
+ let proxyServer;
30
+
31
+ afterEach(async () => {
32
+ await closeServer(proxyServer);
33
+ await closeServer(upstreamServer);
34
+ proxyServer = undefined;
35
+ upstreamServer = undefined;
36
+ });
37
+
38
+ it('forwards event streams with nginx-safe headers', async () => {
39
+ let seenAuthorization = null;
40
+
41
+ const upstream = express();
42
+ upstream.get('/global/event', (req, res) => {
43
+ seenAuthorization = req.headers.authorization ?? null;
44
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
45
+ res.setHeader('Cache-Control', 'private, max-age=0');
46
+ res.setHeader('X-Upstream-Test', 'ok');
47
+ res.write('data: {"ok":true}\n\n');
48
+ res.end();
49
+ });
50
+ upstreamServer = await listen(upstream);
51
+ const upstreamPort = upstreamServer.address().port;
52
+
53
+ const app = express();
54
+ registerOpenCodeProxy(app, {
55
+ fs: {},
56
+ os: {},
57
+ path,
58
+ OPEN_CODE_READY_GRACE_MS: 0,
59
+ getRuntime: () => ({
60
+ openCodePort: upstreamPort,
61
+ isOpenCodeReady: true,
62
+ openCodeNotReadySince: 0,
63
+ isRestartingOpenCode: false,
64
+ }),
65
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
66
+ buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`,
67
+ ensureOpenCodeApiPrefix: () => {},
68
+ });
69
+ proxyServer = await listen(app);
70
+ const proxyPort = proxyServer.address().port;
71
+
72
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/api/global/event`, {
73
+ headers: { Accept: 'text/event-stream' },
74
+ });
75
+
76
+ expect(response.status).toBe(200);
77
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
78
+ expect(response.headers.get('cache-control')).toBe('no-cache');
79
+ expect(response.headers.get('x-accel-buffering')).toBe('no');
80
+ expect(response.headers.get('x-upstream-test')).toBe('ok');
81
+ expect(await response.text()).toBe('data: {"ok":true}\n\n');
82
+ expect(seenAuthorization).toBe('Bearer test-token');
83
+ });
84
+
85
+ it('waits for drain when writing to a slow SSE response', async () => {
86
+ const writes = [];
87
+ const res = new EventEmitter();
88
+ res.writableEnded = false;
89
+ res.destroyed = false;
90
+ res.write = (value) => {
91
+ writes.push(value);
92
+ return false;
93
+ };
94
+ const controller = new AbortController();
95
+
96
+ const write = writeSseChunkWithBackpressure(res, Buffer.from('data: {"ok":true}\n\n'), controller.signal);
97
+
98
+ await new Promise((resolve) => setTimeout(resolve, 0));
99
+ expect(writes).toHaveLength(1);
100
+
101
+ res.emit('drain');
102
+
103
+ await expect(write).resolves.toBe(true);
104
+ });
105
+
106
+ it('tracks whether a raw SSE stream is between event blocks', () => {
107
+ const tracker = createSseBoundaryTracker();
108
+
109
+ expect(tracker.isAtBoundary()).toBe(true);
110
+ expect(tracker.observe(Buffer.from('id: evt-1\n'))).toBe(false);
111
+ expect(tracker.observe(Buffer.from('data: {"ok"'))).toBe(false);
112
+ expect(tracker.observe(Buffer.from(':true}\n'))).toBe(false);
113
+ expect(tracker.observe(Buffer.from('\n'))).toBe(true);
114
+ expect(tracker.observe(Buffer.from('data: next\r\n\r\n'))).toBe(true);
115
+ });
116
+
117
+ it('routes generic API requests through external OpenCode base URL', async () => {
118
+ const upstream = express();
119
+ upstream.get('/config/providers', (_req, res) => {
120
+ res.json({ ok: true, source: 'external-host' });
121
+ });
122
+ upstreamServer = await listen(upstream);
123
+ const upstreamPort = upstreamServer.address().port;
124
+ const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
125
+
126
+ const app = express();
127
+ registerOpenCodeProxy(app, {
128
+ fs: {},
129
+ os: {},
130
+ path,
131
+ OPEN_CODE_READY_GRACE_MS: 0,
132
+ getRuntime: () => ({
133
+ openCodePort: 3902,
134
+ openCodeBaseUrl: externalBaseUrl,
135
+ isOpenCodeReady: true,
136
+ openCodeNotReadySince: 0,
137
+ isRestartingOpenCode: false,
138
+ }),
139
+ getOpenCodeAuthHeaders: () => ({}),
140
+ buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
141
+ ensureOpenCodeApiPrefix: () => {},
142
+ });
143
+ proxyServer = await listen(app);
144
+ const proxyPort = proxyServer.address().port;
145
+
146
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/api/config/providers`);
147
+
148
+ expect(response.status).toBe(200);
149
+ expect(await response.json()).toEqual({ ok: true, source: 'external-host' });
150
+ });
151
+ });