@vantran-se/openchamber-web 1.24.4 → 2.0.1

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 (367) hide show
  1. package/README.md +2 -8
  2. package/bin/lib/commands-connect-url.js +3 -8
  3. package/bin/lib/commands-tunnel.js +3 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
  5. package/dist/assets/DiffView-CjxUovbM.js +4 -0
  6. package/dist/assets/FilesView-DFWD0RQR.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-3sGwqexh.js} +1 -1
  11. package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
  14. package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
  17. package/dist/assets/PlanView-BCk_VobP.js +5 -0
  18. package/dist/assets/PluginPane-BPP1dbck.js +3 -0
  19. package/dist/assets/SettingsView-kMmrpn43.js +29 -0
  20. package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
  22. package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
  29. package/dist/assets/de-CndEg67T.js +10 -0
  30. package/dist/assets/es-DglvPlaC.js +16 -0
  31. package/dist/assets/fr-DejPCpbS.js +14 -0
  32. package/dist/assets/index-C2r64QfD.js +1 -0
  33. package/dist/assets/index-CLcM8HKx.css +1 -0
  34. package/dist/assets/index-CvLgnuNI.js +41 -0
  35. package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-B-mGgeKl.js} +1 -1
  37. package/dist/assets/ja-CVpQawCd.js +16 -0
  38. package/dist/assets/ko-Bw446h9j.js +16 -0
  39. package/dist/assets/main-Bz4WWXbr.js +2 -0
  40. package/dist/assets/main-oreqDsOV.js +896 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-D2e5sp9y.js} +4 -4
  42. package/dist/assets/miniChat-D1v2gsBI.js +2 -0
  43. package/dist/assets/mobile-DBj3lCf2.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-BpeUOV91.js} +3 -3
  45. package/dist/assets/pl-BYNlPdhm.js +16 -0
  46. package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
  48. package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
  50. package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
  53. package/dist/assets/tr-DzmbxLy-.js +7 -0
  54. package/dist/assets/uk-C7vQnwro.js +16 -0
  55. package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
  59. package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
  60. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
  61. package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
  62. package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
  63. package/dist/assets/{web-components-DxX8bZ1o.js → web-components-B6tKQyJN.js} +2 -2
  64. package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
  65. package/dist/assets/zh-TW-DtII5bDb.js +16 -0
  66. package/dist/index.html +13 -7
  67. package/dist/mini-chat.html +5 -5
  68. package/dist/mobile.html +5 -5
  69. package/package.json +6 -5
  70. package/server/index.d.ts +1 -0
  71. package/server/index.js +246 -91
  72. package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
  73. package/server/lib/agent-tool/runtime.js +135 -54
  74. package/server/lib/agent-tool/runtime.test.js +155 -87
  75. package/server/lib/browser-control/DOCUMENTATION.md +37 -2
  76. package/server/lib/browser-control/provider.js +217 -0
  77. package/server/lib/browser-control/provider.test.js +251 -0
  78. package/server/lib/context-obligatory/runtime.js +61 -50
  79. package/server/lib/context-obligatory/runtime.test.js +62 -162
  80. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  81. package/server/lib/event-stream/delta-coalescer.js +0 -0
  82. package/server/lib/event-stream/delta-coalescer.test.js +68 -57
  83. package/server/lib/event-stream/directory-ws-bridge.js +1 -1
  84. package/server/lib/event-stream/global-hub.js +10 -1
  85. package/server/lib/event-stream/global-hub.test.js +27 -26
  86. package/server/lib/event-stream/index.js +6 -0
  87. package/server/lib/event-stream/protocol.js +26 -19
  88. package/server/lib/event-stream/protocol.test.js +18 -7
  89. package/server/lib/event-stream/rebind.test.js +5 -5
  90. package/server/lib/event-stream/runtime.test.js +17 -15
  91. package/server/lib/event-stream/translate-v2.js +313 -0
  92. package/server/lib/event-stream/translate-v2.test.js +209 -0
  93. package/server/lib/event-stream/upstream-reader.test.js +16 -16
  94. package/server/lib/fs/DOCUMENTATION.md +4 -2
  95. package/server/lib/fs/byte-range.js +35 -0
  96. package/server/lib/fs/byte-range.test.js +31 -0
  97. package/server/lib/fs/routes.js +47 -42
  98. package/server/lib/fs/routes.test.js +123 -18
  99. package/server/lib/git/service.js +12 -0
  100. package/server/lib/github/DOCUMENTATION.md +1 -1
  101. package/server/lib/github/pr-status.js +22 -2
  102. package/server/lib/github/pr-status.test.js +27 -14
  103. package/server/lib/guests/DOCUMENTATION.md +13 -0
  104. package/server/lib/guests/catalog.js +12 -0
  105. package/server/lib/guests/catalog.test.js +33 -0
  106. package/server/lib/guests/routes.js +10 -1
  107. package/server/lib/guests/service.js +192 -14
  108. package/server/lib/guests/service.test.js +201 -1
  109. package/server/lib/guests/surface.js +630 -0
  110. package/server/lib/guests/surface.test.js +486 -0
  111. package/server/lib/markdown-image-grants/routes.js +18 -5
  112. package/server/lib/markdown-image-grants/routes.test.js +9 -6
  113. package/server/lib/message-queue/DOCUMENTATION.md +33 -22
  114. package/server/lib/message-queue/runtime.js +155 -100
  115. package/server/lib/message-queue/runtime.test.js +168 -82
  116. package/server/lib/notifications/DOCUMENTATION.md +28 -0
  117. package/server/lib/notifications/control-stream.http.test.js +68 -0
  118. package/server/lib/notifications/emitter-runtime.js +15 -0
  119. package/server/lib/notifications/emitter-runtime.test.js +32 -0
  120. package/server/lib/notifications/push-runtime.js +5 -1
  121. package/server/lib/notifications/push-runtime.test.js +2 -2
  122. package/server/lib/notifications/routes.js +1 -1
  123. package/server/lib/notifications/runtime.js +115 -26
  124. package/server/lib/notifications/template-runtime.js +13 -15
  125. package/server/lib/notifications/template-runtime.test.js +16 -9
  126. package/server/lib/notifications/trigger-runtime.test.js +187 -0
  127. package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
  128. package/server/lib/openchamber-control/actions.js +3 -2
  129. package/server/lib/openchamber-control/error.js +1 -1
  130. package/server/lib/openchamber-control/file-open.js +59 -0
  131. package/server/lib/openchamber-control/file-open.test.js +57 -0
  132. package/server/lib/openchamber-control/routes.test.js +12 -0
  133. package/server/lib/openchamber-control/service.js +102 -49
  134. package/server/lib/openchamber-control/service.test.js +84 -44
  135. package/server/lib/openchamber-sessions/archive-store.js +224 -0
  136. package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
  137. package/server/lib/openchamber-sessions/opencode-client.js +22 -0
  138. package/server/lib/openchamber-sessions/routes.js +398 -328
  139. package/server/lib/openchamber-sessions/routes.test.js +777 -740
  140. package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
  141. package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
  142. package/server/lib/opencode/DOCUMENTATION.md +449 -29
  143. package/server/lib/opencode/agents.js +233 -327
  144. package/server/lib/opencode/auth.js +41 -44
  145. package/server/lib/opencode/auth.test.js +92 -0
  146. package/server/lib/opencode/cli-upgrade.d.ts +4 -0
  147. package/server/lib/opencode/cli-upgrade.js +18 -0
  148. package/server/lib/opencode/cli-upgrade.test.js +36 -0
  149. package/server/lib/opencode/commands.js +164 -145
  150. package/server/lib/opencode/compatibility.d.ts +16 -0
  151. package/server/lib/opencode/compatibility.js +88 -0
  152. package/server/lib/opencode/compatibility.test.js +62 -0
  153. package/server/lib/opencode/config-entity-routes.js +58 -29
  154. package/server/lib/opencode/config-entity-v2.test.js +290 -0
  155. package/server/lib/opencode/config-mutation-response.js +17 -7
  156. package/server/lib/opencode/config-mutation-response.test.js +4 -7
  157. package/server/lib/opencode/config-paths.test.js +2 -2
  158. package/server/lib/opencode/config-v2.d.ts +183 -0
  159. package/server/lib/opencode/config-v2.js +801 -0
  160. package/server/lib/opencode/config-v2.test.js +466 -0
  161. package/server/lib/opencode/credential-db.js +144 -0
  162. package/server/lib/opencode/credential-db.test.js +104 -0
  163. package/server/lib/opencode/env-runtime.js +48 -9
  164. package/server/lib/opencode/env-runtime.test.js +45 -0
  165. package/server/lib/opencode/feature-routes-runtime.js +15 -5
  166. package/server/lib/opencode/lifecycle-process.test.js +7 -4
  167. package/server/lib/opencode/lifecycle.js +85 -30
  168. package/server/lib/opencode/lifecycle.test.js +80 -8
  169. package/server/lib/opencode/login-shell-env.js +27 -0
  170. package/server/lib/opencode/managed-config-file.js +119 -0
  171. package/server/lib/opencode/managed-config-file.test.js +152 -0
  172. package/server/lib/opencode/managed-plugin-config.js +33 -13
  173. package/server/lib/opencode/mcp.js +43 -149
  174. package/server/lib/opencode/network-runtime.js +6 -7
  175. package/server/lib/opencode/openchamber-routes.test.js +2 -5
  176. package/server/lib/opencode/plugin-routes.js +3 -3
  177. package/server/lib/opencode/plugin-routes.test.js +6 -24
  178. package/server/lib/opencode/plugins.js +91 -40
  179. package/server/lib/opencode/plugins.test.js +28 -15
  180. package/server/lib/opencode/providers.js +63 -83
  181. package/server/lib/opencode/providers.test.js +128 -59
  182. package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
  183. package/server/lib/opencode/proxy.js +179 -129
  184. package/server/lib/opencode/pwa-manifest-routes.js +4 -2
  185. package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
  186. package/server/lib/opencode/response-envelope.js +14 -0
  187. package/server/lib/opencode/response-envelope.test.js +23 -0
  188. package/server/lib/opencode/routes-upgrade.test.js +73 -116
  189. package/server/lib/opencode/routes.js +121 -428
  190. package/server/lib/opencode/server-utils-runtime.js +10 -4
  191. package/server/lib/opencode/session-activity.js +78 -0
  192. package/server/lib/opencode/session-runtime.js +21 -10
  193. package/server/lib/opencode/session-runtime.test.js +9 -9
  194. package/server/lib/opencode/settings-helpers.js +9 -9
  195. package/server/lib/opencode/settings-helpers.test.js +7 -12
  196. package/server/lib/opencode/settings-registry.json +6 -8
  197. package/server/lib/opencode/settings-runtime.js +18 -1
  198. package/server/lib/opencode/settings-runtime.test.js +31 -2
  199. package/server/lib/opencode/shared.js +54 -22
  200. package/server/lib/opencode/shared.test.js +8 -4
  201. package/server/lib/opencode/shutdown-runtime.js +54 -7
  202. package/server/lib/opencode/shutdown-runtime.test.js +224 -30
  203. package/server/lib/opencode/skill-routes.js +23 -24
  204. package/server/lib/opencode/skills.js +7 -5
  205. package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
  206. package/server/lib/opencode/upgrade-capability.test.js +1 -1
  207. package/server/lib/opencode/v1-migration-topup.js +288 -0
  208. package/server/lib/opencode/v1-migration-topup.test.js +266 -0
  209. package/server/lib/opencode/v2-install.d.ts +8 -0
  210. package/server/lib/opencode/v2-install.js +158 -0
  211. package/server/lib/opencode/v2-install.test.js +124 -0
  212. package/server/lib/opencode/watcher.js +10 -3
  213. package/server/lib/opencode/watcher.test.js +17 -21
  214. package/server/lib/opencode/websearch-config.js +66 -0
  215. package/server/lib/package-manager.js +55 -68
  216. package/server/lib/package-manager.test.js +241 -75
  217. package/server/lib/permission-auto-accept/runtime.js +38 -22
  218. package/server/lib/permission-auto-accept/runtime.test.js +39 -17
  219. package/server/lib/quota/DOCUMENTATION.md +4 -0
  220. package/server/lib/quota/providers/xai.js +4 -4
  221. package/server/lib/relay/tunnel-host.js +4 -1
  222. package/server/lib/relay/tunnel-host.test.js +7 -0
  223. package/server/lib/routing/DOCUMENTATION.md +52 -21
  224. package/server/lib/routing/defaults.js +23 -6
  225. package/server/lib/routing/jev.js +17 -7
  226. package/server/lib/routing/routes.http.test.js +68 -33
  227. package/server/lib/routing/routes.js +65 -22
  228. package/server/lib/routing/runtime.js +145 -48
  229. package/server/lib/routing/runtime.test.js +79 -55
  230. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
  231. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
  232. package/server/lib/scheduled-tasks/runtime.js +81 -74
  233. package/server/lib/scheduled-tasks/runtime.test.js +53 -1
  234. package/server/lib/session-assist/DOCUMENTATION.md +8 -3
  235. package/server/lib/session-assist/context.js +122 -46
  236. package/server/lib/session-assist/context.test.js +117 -49
  237. package/server/lib/session-assist/runtime.js +52 -22
  238. package/server/lib/session-assist/runtime.test.js +145 -173
  239. package/server/lib/session-goal/DOCUMENTATION.md +23 -15
  240. package/server/lib/session-goal/create.js +9 -12
  241. package/server/lib/session-goal/create.test.js +27 -28
  242. package/server/lib/session-goal/runtime.js +164 -77
  243. package/server/lib/session-goal/runtime.test.js +271 -519
  244. package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
  245. package/server/lib/session-knowledge/runtime.js +59 -41
  246. package/server/lib/session-knowledge/runtime.test.js +90 -20
  247. package/server/lib/small-model/DOCUMENTATION.md +154 -208
  248. package/server/lib/small-model/client.js +113 -0
  249. package/server/lib/small-model/index.js +296 -158
  250. package/server/lib/small-model/index.test.js +423 -256
  251. package/server/lib/small-model/routes.js +1 -1
  252. package/server/lib/spaces/DOCUMENTATION.md +807 -0
  253. package/server/lib/spaces/code-in-bait.js +185 -0
  254. package/server/lib/spaces/code-in.js +641 -0
  255. package/server/lib/spaces/code-in.test.js +1048 -0
  256. package/server/lib/spaces/errors.js +8 -0
  257. package/server/lib/spaces/exec-http.js +94 -0
  258. package/server/lib/spaces/gatekeeper-channel.js +202 -0
  259. package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
  260. package/server/lib/spaces/gatekeeper-program.cjs +914 -0
  261. package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
  262. package/server/lib/spaces/hardening.js +457 -0
  263. package/server/lib/spaces/hardening.test.js +368 -0
  264. package/server/lib/spaces/host-git.js +127 -0
  265. package/server/lib/spaces/host-git.test.js +137 -0
  266. package/server/lib/spaces/labels.js +234 -0
  267. package/server/lib/spaces/labels.test.js +219 -0
  268. package/server/lib/spaces/layout.js +184 -0
  269. package/server/lib/spaces/layout.test.js +30 -0
  270. package/server/lib/spaces/manager.js +82 -0
  271. package/server/lib/spaces/manager.test.js +114 -0
  272. package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
  273. package/server/lib/spaces/places/contract-suite.js +156 -0
  274. package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
  275. package/server/lib/spaces/places/contract.test.js +6 -0
  276. package/server/lib/spaces/places/docker-engine.js +126 -0
  277. package/server/lib/spaces/places/docker-live-support.js +202 -0
  278. package/server/lib/spaces/places/docker-tools.js +186 -0
  279. package/server/lib/spaces/places/docker.js +587 -0
  280. package/server/lib/spaces/places/docker.test.js +1610 -0
  281. package/server/lib/spaces/places/escape-suite.js +1018 -0
  282. package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
  283. package/server/lib/spaces/places/fake-docker.js +375 -0
  284. package/server/lib/spaces/places/memory-place.js +103 -0
  285. package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
  286. package/server/lib/spaces/places/registry.js +43 -0
  287. package/server/lib/spaces/places/registry.test.js +50 -0
  288. package/server/lib/spaces/places/server.docker.live.test.js +225 -0
  289. package/server/lib/spaces/run-command.js +261 -0
  290. package/server/lib/spaces/run-command.test.js +396 -0
  291. package/server/lib/spaces/space-server.js +190 -0
  292. package/server/lib/spaces/space-server.test.js +335 -0
  293. package/server/lib/spaces/tools-filler.js +82 -0
  294. package/server/lib/spaces/tools-filler.test.js +148 -0
  295. package/server/lib/spaces/tools-pack.js +49 -0
  296. package/server/lib/spaces/tools-pack.test.js +57 -0
  297. package/server/lib/spaces/tools.js +138 -0
  298. package/server/lib/spaces/tools.test.js +153 -0
  299. package/server/lib/tts/openai-sdk.js +11 -0
  300. package/server/lib/tts/service.js +7 -5
  301. package/server/lib/tts/stt.js +3 -2
  302. package/server/lib/tunnels/DOCUMENTATION.md +2 -0
  303. package/server/lib/tunnels/routes.js +7 -0
  304. package/server/lib/tunnels/routes.test.js +55 -0
  305. package/server/lib/ui-auth/ui-auth.js +38 -2
  306. package/server/lib/ui-auth/ui-auth.test.js +42 -0
  307. package/server/lib/ui-auth/ui-passkeys.js +12 -10
  308. package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
  309. package/server/lib/walkthrough/pull-request.js +61 -8
  310. package/server/lib/walkthrough/pull-request.test.js +54 -1
  311. package/server/lib/walkthrough/routes.js +20 -0
  312. package/server/lib/walkthrough/routes.test.js +19 -0
  313. package/server/opencode-proxy.test.js +190 -166
  314. package/server/opencode-worktree-readiness.test.js +5 -4
  315. package/dist/assets/DiffView-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -0,0 +1,335 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SpaceError } from './errors.js';
4
+ import { createSpaceServerChannel, createSpaceToken } from './space-server.js';
5
+
6
+ const ID = 'a1b2c3d4e5f6';
7
+ const http = (status, body, headers = []) => `HTTP/1.1 ${status} OK\r\n${['Content-Type: application/json', ...headers].join('\r\n')}\r\n\r\n${body}`;
8
+ const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' });
9
+
10
+ /**
11
+ * A channel on a recording `exec`. `answers` are returned in turn, the last one repeats.
12
+ * Nothing sleeps: the clock moves by the pause in `wait`, and by `secondsPerAttempt` for every exec.
13
+ */
14
+ const channelWith = (...answers) => channelTaking(0, ...answers);
15
+
16
+ const channelTaking = (secondsPerAttempt, ...answers) => {
17
+ const calls = [];
18
+ let waits = 0;
19
+ let clock = 1_000_000;
20
+ const exec = async (spaceId, argv, options) => {
21
+ calls.push({ spaceId, argv, options });
22
+ clock += secondsPerAttempt * 1000;
23
+ const answer = answers.length > 1 ? answers.shift() : answers[0];
24
+ if (answer instanceof Error) throw answer;
25
+ return answer;
26
+ };
27
+ const wait = async (milliseconds) => { waits += 1; clock += milliseconds; };
28
+ return { channel: createSpaceServerChannel({ exec, wait, now: () => clock }), calls, waits: () => waits };
29
+ };
30
+
31
+ describe('createSpaceToken', () => {
32
+ it('is 32 random bytes in a form that needs no quoting', () => {
33
+ expect(createSpaceToken()).toMatch(/^[A-Za-z0-9_-]{43}$/);
34
+ expect(createSpaceToken()).not.toBe(createSpaceToken());
35
+ });
36
+ });
37
+
38
+ describe('space server channel: token', () => {
39
+ it('sends the token on stdin to a fixed script, through a temporary name, readable by the space user only', async () => {
40
+ const { channel, calls } = channelWith(ok());
41
+ await channel.writeToken(ID, 'the-token');
42
+
43
+ expect(calls).toEqual([{
44
+ spaceId: ID,
45
+ argv: ['/bin/sh', '-c', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; umask 077; mkdir -p /home/space/.openchamber-space && chmod 700 /home/space/.openchamber-space && cat > /home/space/.openchamber-space/token.new && mv /home/space/.openchamber-space/token.new /home/space/.openchamber-space/token'],
46
+ options: { stdin: 'the-token' },
47
+ }]);
48
+ });
49
+
50
+ it('reads the token back from the space, with a short time limit', async () => {
51
+ const token = createSpaceToken();
52
+ const { channel, calls } = channelWith(ok(`${token}\n`));
53
+
54
+ expect(await channel.readToken(ID)).toBe(token);
55
+ expect(calls[0].argv).toEqual(['/bin/cat', '/home/space/.openchamber-space/token']);
56
+ expect(calls[0].options.timeoutMs).toBe(10_000);
57
+ });
58
+
59
+ // The agent owns the token file.
60
+ it.each([
61
+ ['an empty file', ok('\n'), /is empty/],
62
+ ['a file that holds something else', ok('not a token: $(reboot)\n'), /does not hold a token/],
63
+ ['a token that is too short', ok('abc\n'), /does not hold a token/],
64
+ ['a token that is too long', ok(`${'a'.repeat(129)}\n`), /does not hold a token/],
65
+ ['a huge file', Object.assign(new Error('too much'), { code: 'command_output_too_large' }), /far too large/],
66
+ ['a FIFO that never ends', Object.assign(new Error('too slow'), { code: 'command_timeout' }), /in time/],
67
+ ])('rejects %s with a code of its own', async (title, answer, message) => {
68
+ const { channel } = channelWith(answer);
69
+
70
+ const error = await channel.readToken(ID).catch((caught) => caught);
71
+ expect(error).toBeInstanceOf(SpaceError);
72
+ expect(error.code).toBe('space_token_unreadable');
73
+ expect(error.message).toMatch(message);
74
+ });
75
+
76
+ it('says when the token file cannot be read at all', async () => {
77
+ const { channel } = channelWith({ code: 1, stdout: '', stderr: 'cat: /home/space/.openchamber-space/token: No such file or directory\n' });
78
+ await expect(channel.readToken(ID)).rejects.toMatchObject({ code: 'space_setup_failed', message: expect.stringContaining('No such file') });
79
+ });
80
+
81
+ it('says what failed inside the space', async () => {
82
+ const { channel } = channelWith({ code: 1, stdout: '', stderr: 'cat: write error: No space left on device\n' });
83
+ await expect(channel.writeToken(ID, 'x')).rejects.toMatchObject({ code: 'space_setup_failed', message: expect.stringContaining('No space left on device') });
84
+ });
85
+ });
86
+
87
+ describe('space server channel: plugin link', () => {
88
+ it('links only the plugin package, above every project of the space', async () => {
89
+ const { channel, calls } = channelWith(ok());
90
+ await channel.linkPlugin(ID);
91
+
92
+ expect(calls[0].argv).toEqual([
93
+ '/bin/sh', '-c', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; mkdir -p "$1/node_modules/@opencode" && ln -sfn "$2" "$1/node_modules/@opencode/plugin"',
94
+ 'sh', `/spaces/${ID}`, '/opt/openchamber-tools/node_modules/@opencode/plugin',
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe('space server channel: request', () => {
100
+ it('puts the whole request into a curl config on stdin, so nothing of it is in an argument', async () => {
101
+ const { channel, calls } = channelWith(ok(http(200, '{"authenticated":true}', ['Set-Cookie: a=1; Path=/', 'Set-Cookie: b=2'])));
102
+ const body = JSON.stringify({ password: 'quote " backslash \\ newline \n tab \t end' });
103
+ const answer = await channel.request(ID, { method: 'POST', path: '/auth/session', headers: { 'Content-Type': 'application/json', Cookie: 'oc=secret' }, body });
104
+
105
+ expect(answer).toEqual({
106
+ status: 200,
107
+ headers: { 'content-type': ['application/json'], 'set-cookie': ['a=1; Path=/', 'b=2'] },
108
+ body: '{"authenticated":true}',
109
+ });
110
+ expect(calls[0].argv).toEqual(['/usr/bin/curl', '--disable', '--noproxy', '*', '--silent', '--show-error', '--include', '--max-time', '20', '--config', '-']);
111
+ expect(calls[0].options.stdin).toBe([
112
+ 'url = "http://127.0.0.1:27600/auth/session"',
113
+ 'request = "POST"',
114
+ 'header = "Expect:"',
115
+ 'header = "Content-Type: application/json"',
116
+ 'header = "Cookie: oc=secret"',
117
+ 'data-raw = "{\\"password\\":\\"quote \\\\\\" backslash \\\\\\\\ newline \\\\n tab \\\\t end\\"}"',
118
+ '',
119
+ ].join('\n'));
120
+ expect(calls[0].options.timeoutMs).toBeGreaterThan(20_000);
121
+ });
122
+
123
+ it('puts --disable first and --noproxy on every request, whatever the space environment says', async () => {
124
+ const { channel, calls } = channelWith(ok(http(200, '')));
125
+ await channel.request(ID, { path: '/health' });
126
+ await channel.waitUntilReady(ID).catch(() => {});
127
+
128
+ // curl honours --disable only as its first argument. Without --noproxy the space's own
129
+ // proxy variables send this request to the corridor: measured, the answer was
130
+ // `curl: (1) Received HTTP/0.9 when not allowed`.
131
+ for (const call of calls) expect(call.argv.slice(0, 4)).toEqual(['/usr/bin/curl', '--disable', '--noproxy', '*']);
132
+ });
133
+
134
+ it('sends a body that starts with @ as text, not as the content of a file', async () => {
135
+ const { channel, calls } = channelWith(ok(http(200, '')));
136
+ await channel.request(ID, { method: 'POST', path: '/x', body: '@/home/space/.openchamber-space/token' });
137
+
138
+ const lines = calls[0].options.stdin.split('\n');
139
+ expect(lines).toContain('data-raw = "@/home/space/.openchamber-space/token"');
140
+ expect(lines.some((line) => line.startsWith('data-binary') || line.startsWith('data ='))).toBe(false);
141
+ });
142
+
143
+ it('resolves any status, and keeps a body that holds a blank line', async () => {
144
+ const { channel } = channelWith(ok(http(401, 'first\r\n\r\nsecond')));
145
+ expect(await channel.request(ID, { path: '/api/session' })).toMatchObject({ status: 401, body: 'first\r\n\r\nsecond' });
146
+ });
147
+
148
+ it('keeps a line break inside a header value from becoming a second config line', async () => {
149
+ const { channel, calls } = channelWith(ok(http(200, '')));
150
+ await channel.request(ID, { path: '/health', headers: { 'X-Test': 'a\nurl = "http://elsewhere"' } });
151
+ expect(calls[0].options.stdin.split('\n')).toHaveLength(5);
152
+ });
153
+
154
+ it('rejects a NUL character, which a curl config cannot carry', async () => {
155
+ const { channel, calls } = channelWith(ok(http(200, '')));
156
+ await expect(channel.request(ID, { path: '/health', body: 'a\0b' })).rejects.toMatchObject({ code: 'invalid_request' });
157
+ expect(calls).toEqual([]);
158
+ });
159
+
160
+ it('says when nothing answers, with the curl exit code', async () => {
161
+ const { channel } = channelWith({ code: 7, stdout: '', stderr: 'curl: (7) Failed to connect to 127.0.0.1 port 27600\n' });
162
+ await expect(channel.request(ID, { path: '/health' })).rejects.toMatchObject({ code: 'space_server_unreachable', details: { curlExitCode: 7 } });
163
+ });
164
+
165
+ it('rejects an answer that is not HTTP', async () => {
166
+ const { channel } = channelWith(ok('garbage'));
167
+ await expect(channel.request(ID, { path: '/health' })).rejects.toMatchObject({ code: 'space_server_answer_unreadable' });
168
+ });
169
+
170
+ it('takes header names such as __proto__ and constructor as plain names', async () => {
171
+ const { channel } = channelWith(ok(http(200, '{}', ['__proto__: polluted', 'constructor: x', 'toString: y', 'no colon here', ': empty name'])));
172
+ const answer = await channel.request(ID, { path: '/health' });
173
+
174
+ expect(answer.headers.__proto__).toEqual(['polluted']);
175
+ expect(answer.headers.constructor).toEqual(['x']);
176
+ expect(answer.headers.tostring).toEqual(['y']);
177
+ expect(Object.keys(answer.headers)).toEqual(['content-type', '__proto__', 'constructor', 'tostring']);
178
+ expect({}.polluted).toBeUndefined();
179
+ });
180
+
181
+ it('parses tens of thousands of repeated header lines in linear time, and refuses them by the cap', async () => {
182
+ // The old code copied an array per line and blocked the event loop for seconds on an answer like this.
183
+ // Now the cap on the header block refuses it before any line is looked at.
184
+ const lines = Array.from({ length: 50_000 }, () => 'x: 1');
185
+ const { channel } = channelWith(ok(http(200, '{}', lines)));
186
+
187
+ const started = performance.now();
188
+ const error = await channel.request(ID, { path: '/health' }).catch((caught) => caught);
189
+ expect(performance.now() - started).toBeLessThan(1_000);
190
+ expect(error).toBeInstanceOf(SpaceError);
191
+ expect(error.code).toBe('space_server_answer_unreadable');
192
+ });
193
+
194
+ it('accepts 200 header lines, also of one repeated name, and refuses 201', async () => {
195
+ const accepted = channelWith(ok(http(200, '{}', Array.from({ length: 199 }, (unused, index) => `Set-Cookie: c${index}=1`))));
196
+ const answer = await accepted.channel.request(ID, { path: '/health' });
197
+ expect(answer.headers['set-cookie']).toHaveLength(199);
198
+ expect(answer.headers['set-cookie'][198]).toBe('c198=1');
199
+
200
+ const refused = channelWith(ok(http(200, '{}', Array.from({ length: 200 }, () => 'x: 1'))));
201
+ await expect(refused.channel.request(ID, { path: '/health' })).rejects.toMatchObject({ code: 'space_server_answer_unreadable', message: expect.stringContaining('200 header lines') });
202
+ });
203
+
204
+ it('refuses a header block of more than 64 KiB before it looks at its lines', async () => {
205
+ const { channel } = channelWith(ok(http(200, '{}', [`x: ${'a'.repeat(70_000)}`])));
206
+ await expect(channel.request(ID, { path: '/health' })).rejects.toMatchObject({ code: 'space_server_answer_unreadable', message: expect.stringContaining('65536 bytes of headers') });
207
+ });
208
+
209
+ it('maps a request whose exec timed out or was killed to "unreachable", never to an interrupted Docker step', async () => {
210
+ for (const code of ['command_timeout', 'command_killed']) {
211
+ const { channel } = channelWith(Object.assign(new Error('docker exec did not finish'), { code }));
212
+ const error = await channel.request(ID, { path: '/health' }).catch((caught) => caught);
213
+ expect(error).toBeInstanceOf(SpaceError);
214
+ expect(error.code).toBe('space_server_unreachable');
215
+ }
216
+ });
217
+
218
+ it('maps more output than the host accepts to an unreadable answer, never to an interrupted Docker step', async () => {
219
+ const tooLarge = Object.assign(new Error('docker exec printed more than 4194304 bytes and was stopped'), { code: 'command_output_too_large' });
220
+ const { channel } = channelWith(tooLarge);
221
+
222
+ const error = await channel.request(ID, { path: '/health' }).catch((caught) => caught);
223
+ expect(error).toBeInstanceOf(SpaceError);
224
+ expect(error.code).toBe('space_server_answer_unreadable');
225
+ });
226
+
227
+ it('cuts the text from inside the space in an error message to 2,000 characters', async () => {
228
+ const { channel } = channelWith({ code: 22, stdout: '', stderr: 'x'.repeat(50_000) });
229
+ const error = await channel.request(ID, { path: '/health' }).catch((caught) => caught);
230
+ expect(error.message.length).toBeLessThan(2_100);
231
+
232
+ const failing = channelWith({ code: 1, stdout: '', stderr: 'y'.repeat(50_000) });
233
+ const setupError = await failing.channel.writeToken(ID, 'x').catch((caught) => caught);
234
+ expect(setupError.message.length).toBeLessThan(2_100);
235
+ });
236
+ });
237
+
238
+ describe('space server channel: waitUntilReady', () => {
239
+ const refused = { code: 7, stdout: '', stderr: 'curl: (7) Failed to connect' };
240
+
241
+ it('waits through "nothing listens yet" and "OpenCode not ready", then resolves', async () => {
242
+ const { channel, calls, waits } = channelWith(refused, refused, ok(http(200, '{"isOpenCodeReady":false}')), ok(http(200, '{"isOpenCodeReady":true}')));
243
+ await channel.waitUntilReady(ID);
244
+
245
+ expect(calls).toHaveLength(4);
246
+ expect(waits()).toBe(3);
247
+ });
248
+
249
+ it('gives up at the deadline and says what it saw last', async () => {
250
+ const { channel, calls } = channelWith(ok(http(200, '{"isOpenCodeReady":false}')));
251
+
252
+ const error = await channel.waitUntilReady(ID).catch((caught) => caught);
253
+ expect(error.code).toBe('space_server_not_ready');
254
+ expect(error.message).toMatch(/within 120 seconds \(the answer does not report OpenCode ready\)/);
255
+ expect(calls).toHaveLength(240);
256
+ });
257
+
258
+ it('asks /health with a short time limit, so a listener that never answers costs little', async () => {
259
+ const { channel, calls } = channelWith(ok(http(200, '{"isOpenCodeReady":true}')));
260
+ await channel.waitUntilReady(ID);
261
+
262
+ expect(calls[0].argv).toEqual(['/usr/bin/curl', '--disable', '--noproxy', '*', '--silent', '--show-error', '--include', '--max-time', '3', '--config', '-']);
263
+ expect(calls[0].options.timeoutMs).toBe(13_000);
264
+ });
265
+
266
+ it('ends at the wall-clock deadline when something on the port accepts and never answers', async () => {
267
+ // Every attempt runs into the time limit of curl: three seconds each, exit code 28.
268
+ const { channel, calls } = channelTaking(3, { code: 28, stdout: '', stderr: 'curl: (28) Operation timed out' });
269
+
270
+ const error = await channel.waitUntilReady(ID).catch((caught) => caught);
271
+ expect(error.code).toBe('space_server_not_ready');
272
+ // 120 seconds of deadline, in steps of 3.5 seconds. The old count of 240 attempts would have been 14 minutes here.
273
+ expect(calls.length).toBeLessThanOrEqual(35);
274
+ expect(error.message).toMatch(/within 12\d seconds/);
275
+ });
276
+
277
+ it('still ends when the clock of the caller stands still', async () => {
278
+ const calls = [];
279
+ const exec = async (spaceId, argv) => { calls.push(argv); return { code: 7, stdout: '', stderr: '' }; };
280
+ const channel = createSpaceServerChannel({ exec, wait: async () => {}, now: () => 5 });
281
+
282
+ await expect(channel.waitUntilReady(ID)).rejects.toMatchObject({ code: 'space_server_not_ready' });
283
+ expect(calls).toHaveLength(1_000);
284
+ });
285
+
286
+ it.each([
287
+ ['a body of null', ok(http(200, 'null'))],
288
+ ['a body that is a number', ok(http(200, '5'))],
289
+ ['a body that is not JSON', ok(http(200, '<html>'))],
290
+ ['an empty body', ok(http(200, ''))],
291
+ ['a ready answer with another status', ok(http(503, '{"isOpenCodeReady":true}'))],
292
+ ['a truthy value that is not true', ok(http(200, '{"isOpenCodeReady":"yes"}'))],
293
+ // Ready, but far larger than a health answer. The host does not spend time parsing it.
294
+ ['a ready body of more than 64 KiB', ok(http(200, `{"isOpenCodeReady":true,"padding":"${'x'.repeat(70 * 1024)}"}`))],
295
+ // Measured with curl 7.88.1 against a listener that answers `hello`: exit code 1, the code that a failed `docker exec` uses too.
296
+ ['a listener that does not speak HTTP', { code: 1, stdout: '', stderr: 'curl: (1) Received HTTP/0.9 when not allowed\n' }],
297
+ ['a request whose exec timed out', Object.assign(new Error('docker exec did not finish'), { code: 'command_timeout' })],
298
+ ['more header lines than the host parses', ok(http(200, '{"isOpenCodeReady":true}', Array.from({ length: 5_000 }, () => 'x: 1')))],
299
+ ['a header named __proto__', ok(http(200, '{}', ['__proto__: x']))],
300
+ ['curl exit code 56, a reset connection', { code: 56, stdout: '', stderr: 'curl: (56) Recv failure' }],
301
+ ['curl exit code 8, a weird server reply', { code: 8, stdout: '', stderr: 'curl: (8) Weird server reply' }],
302
+ ['more output than the host accepts', Object.assign(new Error('too much'), { code: 'command_output_too_large' })],
303
+ ])('counts %s from inside as "not ready yet", and throws nothing but a SpaceError at the end', async (title, hostile) => {
304
+ const { channel, calls } = channelWith(hostile);
305
+
306
+ const error = await channel.waitUntilReady(ID).catch((caught) => caught);
307
+ expect(error).toBeInstanceOf(SpaceError);
308
+ expect(error.code).toBe('space_server_not_ready');
309
+ expect(calls.length).toBeGreaterThan(1);
310
+ });
311
+
312
+ it('becomes ready after hostile answers, when the real server takes the port', async () => {
313
+ const { channel } = channelWith(ok(http(200, 'null')), { code: 1, stdout: '', stderr: 'curl: (1) Received HTTP/0.9 when not allowed' }, { code: 52, stdout: '', stderr: '' }, ok(http(200, '{"isOpenCodeReady":true}')));
314
+ await channel.waitUntilReady(ID);
315
+ });
316
+
317
+ it('stops at once when the container is gone, instead of waiting for the whole time', async () => {
318
+ const { channel, calls } = channelWith({ code: 1, stdout: '', stderr: 'Error response from daemon: container abc is not running' });
319
+
320
+ await expect(channel.waitUntilReady(ID)).rejects.toMatchObject({ code: 'space_server_unreachable', message: expect.stringContaining('is not running') });
321
+ expect(calls).toHaveLength(1);
322
+ });
323
+
324
+ it.each([1, 125, 126, 127])('stops at once for exit code %i without a curl message, because then docker exec itself failed', async (code) => {
325
+ const { channel, calls } = channelWith({ code, stdout: '', stderr: 'OCI runtime exec failed: exec failed: unable to start container process' });
326
+ await expect(channel.waitUntilReady(ID)).rejects.toMatchObject({ code: 'space_server_unreachable', details: { execFailed: true } });
327
+ expect(calls).toHaveLength(1);
328
+ });
329
+
330
+ it('lets a failure of the place through', async () => {
331
+ const failure = Object.assign(new Error('spawn docker ENOENT'), { code: 'command_spawn_failed' });
332
+ const { channel } = channelWith(failure);
333
+ await expect(channel.waitUntilReady(ID)).rejects.toBe(failure);
334
+ });
335
+ });
@@ -0,0 +1,82 @@
1
+ // The program of the one-shot container that fills a tools volume, and the input it reads.
2
+ //
3
+ // The program is fixed. Only its input varies: one JSON header line with the key and the
4
+ // name and byte length of each file, then the bytes of the files, one after another.
5
+ // The input travels over stdin, because there is no tar library here and none may be added.
6
+
7
+ import { SpaceError } from './errors.js';
8
+ import { requireToolsKey } from './labels.js';
9
+
10
+ const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
11
+ const PACKAGE_JSON_NAME = 'package.json';
12
+
13
+ // Joined with spaces into one line. Docker Desktop on Windows was verified with many
14
+ // special characters in an argument, but never with a newline. So every line here is a
15
+ // whole statement, and there are no `//` comments inside.
16
+ //
17
+ // Arguments: the tools directory and a staging directory under /tmp.
18
+ // `package.json` goes to the tools directory, every other file to the staging directory.
19
+ // The marker vouches for everything npm wrote, and npm flushes nothing. So `sync` runs first, and
20
+ // only then is the marker written: through a temporary name, with the file and its directory
21
+ // flushed before and after the rename. A marker on disk without the content it vouches for would
22
+ // be a broken volume that nothing repairs. A Docker machine that crashes right after a fill can
23
+ // still lose the marker or leave it empty, and the place then removes the volume and fills again.
24
+ // @opencode/cli is the one package whose install script runs: it links the OpenCode binary
25
+ // for this CPU and C library to the path its launcher starts. Without it `opencode` only prints an error.
26
+ const PROGRAM_LINES = [
27
+ "const fs = require('node:fs');",
28
+ "const path = require('node:path');",
29
+ "const { spawnSync } = require('node:child_process');",
30
+ 'const [toolsDirectory, stagingDirectory] = process.argv.slice(1);',
31
+ "const fail = (message) => { console.error('fill failed: ' + message); process.exit(1); };",
32
+ "const run = (file, args, cwd) => { const result = spawnSync(file, args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] }); if (result.status !== 0) fail(file + ' ' + args[0] + ' ended with ' + (result.status === null ? result.signal || result.error : result.status)); };",
33
+ 'const chunks = [];',
34
+ "process.stdin.on('data', (chunk) => chunks.push(chunk));",
35
+ "process.stdin.on('end', () => {",
36
+ ' const input = Buffer.concat(chunks);',
37
+ ' const headerEnd = input.indexOf(10);',
38
+ " if (headerEnd < 0) fail('the input has no header line');",
39
+ ' let header = null;',
40
+ " try { header = JSON.parse(input.subarray(0, headerEnd).toString('utf8')); } catch (error) { fail('the header is not JSON'); }",
41
+ " if (!header || !/^[0-9a-f]{16}$/.test(header.key) || !Array.isArray(header.files)) fail('the header needs a key and a file list');",
42
+ ' fs.mkdirSync(stagingDirectory, { recursive: true });',
43
+ ' let offset = headerEnd + 1;',
44
+ ' for (const file of header.files) {',
45
+ " if (!file || typeof file.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(file.name)) fail('a file name must be a plain name');",
46
+ " if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || offset + file.bytes > input.length) fail('the length of ' + file.name + ' does not fit the input');",
47
+ " fs.writeFileSync(path.join(file.name === 'package.json' ? toolsDirectory : stagingDirectory, file.name), input.subarray(offset, offset + file.bytes));",
48
+ ' offset += file.bytes;',
49
+ ' }',
50
+ " if (offset !== input.length) fail('the input is longer than the header says');",
51
+ " run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--cache', path.join(stagingDirectory, 'npm-cache')], toolsDirectory);",
52
+ " run(process.execPath, ['postinstall.mjs'], path.join(toolsDirectory, 'node_modules', '@opencode/cli'));",
53
+ " for (const needed of ['node_modules/.bin/openchamber', 'node_modules/.bin/opencode', 'node_modules/@opencode/plugin/package.json']) {",
54
+ " if (!fs.existsSync(path.join(toolsDirectory, needed))) fail(needed + ' is missing after the install');",
55
+ ' }',
56
+ " run('/bin/sync', [], toolsDirectory);",
57
+ " const flush = (target, flags) => { const descriptor = fs.openSync(target, flags); try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } };",
58
+ " const marker = path.join(toolsDirectory, '.filled');",
59
+ " fs.writeFileSync(marker + '.new', header.key);",
60
+ " flush(marker + '.new', 'r+');",
61
+ " flush(toolsDirectory, 'r');",
62
+ " fs.renameSync(marker + '.new', marker);",
63
+ " flush(toolsDirectory, 'r');",
64
+ '});',
65
+ ];
66
+
67
+ export const FILLER_PROGRAM = PROGRAM_LINES.map((line) => line.trim()).join(' ');
68
+
69
+ /**
70
+ * The stdin of the filler for one tools source: the header line, then the bytes.
71
+ * `files` are the tarballs of a packed source as `{ name, bytes }`, empty for a registry source.
72
+ */
73
+ export function buildFillInput({ key, packageJson, files = [] }) {
74
+ const entries = [{ name: PACKAGE_JSON_NAME, bytes: Buffer.from(packageJson, 'utf8') }, ...files];
75
+ for (const entry of entries) {
76
+ if (!FILE_NAME_PATTERN.test(entry.name) || !Buffer.isBuffer(entry.bytes)) {
77
+ throw new SpaceError('invalid_tools_file', 'A tools file needs a plain name without slashes and its bytes as a Buffer');
78
+ }
79
+ }
80
+ const header = { key: requireToolsKey(key), files: entries.map((entry) => ({ name: entry.name, bytes: entry.bytes.length })) };
81
+ return Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`, 'utf8'), ...entries.map((entry) => entry.bytes)]);
82
+ }
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+
7
+ import { TOOLS_MARKER_PATH } from './layout.js';
8
+ import { runCommand } from './run-command.js';
9
+ import { FILLER_PROGRAM, buildFillInput } from './tools-filler.js';
10
+
11
+ const KEY = '0123456789abcdef';
12
+
13
+ describe('buildFillInput', () => {
14
+ it('puts a header line with names and byte lengths before the bytes', () => {
15
+ const input = buildFillInput({ key: KEY, packageJson: '{"a":"ä"}', files: [{ name: 'web.tgz', bytes: Buffer.from([0, 10, 255]) }] });
16
+
17
+ const headerEnd = input.indexOf(10);
18
+ expect(JSON.parse(input.subarray(0, headerEnd).toString('utf8'))).toEqual({
19
+ key: KEY,
20
+ files: [{ name: 'package.json', bytes: 10 }, { name: 'web.tgz', bytes: 3 }],
21
+ });
22
+ expect(input.subarray(headerEnd + 1, headerEnd + 11).toString('utf8')).toBe('{"a":"ä"}');
23
+ expect([...input.subarray(headerEnd + 11)]).toEqual([0, 10, 255]);
24
+ });
25
+
26
+ it.each(['../evil.tgz', 'a/b.tgz', '.hidden', '', 'a\\b', 'x'.repeat(101)])('rejects the file name %j', (name) => {
27
+ expect(() => buildFillInput({ key: KEY, packageJson: '{}', files: [{ name, bytes: Buffer.alloc(1) }] })).toThrow(expect.objectContaining({ code: 'invalid_tools_file' }));
28
+ });
29
+
30
+ it('rejects bytes that are not a Buffer, and a bad key', () => {
31
+ expect(() => buildFillInput({ key: KEY, packageJson: '{}', files: [{ name: 'a.tgz', bytes: 'text' }] })).toThrow(expect.objectContaining({ code: 'invalid_tools_file' }));
32
+ expect(() => buildFillInput({ key: 'nope', packageJson: '{}' })).toThrow(expect.objectContaining({ code: 'invalid_tools_key' }));
33
+ });
34
+ });
35
+
36
+ describe('FILLER_PROGRAM', () => {
37
+ it('is one line, so it is safe as one argument on every host', () => {
38
+ expect(FILLER_PROGRAM).not.toMatch(/[\r\n]/);
39
+ expect(() => new Function(FILLER_PROGRAM)).not.toThrow();
40
+ });
41
+
42
+ it('ignores install scripts, keeps the npm cache out of the volume, and writes the marker that the place reads', () => {
43
+ expect(FILLER_PROGRAM).toContain("'--ignore-scripts', '--no-audit', '--no-fund', '--cache'");
44
+ expect(FILLER_PROGRAM).toContain(`'${path.posix.basename(TOOLS_MARKER_PATH)}'`);
45
+ });
46
+ });
47
+
48
+ // The program runs here as it does in the filler, against temporary directories and a stand-in for npm.
49
+ describe.skipIf(process.platform === 'win32')('FILLER_PROGRAM, run', () => {
50
+ const FAKE_NPM = `#!/bin/sh
51
+ echo "$@" > npm-args.txt
52
+ [ -f fail-npm ] && exit 3
53
+ mkdir -p node_modules/.bin node_modules/@opencode/plugin node_modules/@opencode/cli
54
+ touch node_modules/.bin/openchamber node_modules/.bin/opencode
55
+ [ -f skip-plugin ] || echo '{}' > node_modules/@opencode/plugin/package.json
56
+ echo "import fs from 'node:fs'; fs.writeFileSync('postinstall-ran', 'yes');" > node_modules/@opencode/cli/postinstall.mjs
57
+ `;
58
+ let root;
59
+ let tools;
60
+ let staging;
61
+ let originalPath;
62
+
63
+ beforeAll(() => {
64
+ originalPath = process.env.PATH;
65
+ });
66
+
67
+ afterAll(() => {
68
+ process.env.PATH = originalPath;
69
+ });
70
+
71
+ beforeEach(() => {
72
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-filler-test-'));
73
+ tools = path.join(root, 'tools');
74
+ staging = path.join(root, 'staging');
75
+ fs.mkdirSync(tools);
76
+ fs.mkdirSync(path.join(root, 'bin'));
77
+ fs.writeFileSync(path.join(root, 'bin', 'npm'), FAKE_NPM, { mode: 0o755 });
78
+ process.env.PATH = `${path.join(root, 'bin')}${path.delimiter}${originalPath}`;
79
+ });
80
+
81
+ afterEach(() => {
82
+ fs.rmSync(root, { recursive: true, force: true });
83
+ });
84
+
85
+ const fill = (stdin) => runCommand(process.execPath, ['-e', FILLER_PROGRAM, tools, staging], { stdin, timeoutMs: 30_000 });
86
+ const rawInput = (header, ...bodies) => Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), ...bodies.map((body) => Buffer.from(body))]);
87
+
88
+ it('writes package.json to the tools directory, tarballs to staging, installs, and writes the marker last', async () => {
89
+ const bytes = Buffer.from([31, 139, 0, 10, 255, 0]);
90
+ const result = await fill(buildFillInput({ key: KEY, packageJson: '{"name":"x"}', files: [{ name: 'openchamber-web.tgz', bytes }] }));
91
+
92
+ expect(result).toMatchObject({ code: 0 });
93
+ expect(fs.readFileSync(path.join(tools, 'package.json'), 'utf8')).toBe('{"name":"x"}');
94
+ expect(fs.readFileSync(path.join(staging, 'openchamber-web.tgz'))).toEqual(bytes);
95
+ expect(fs.existsSync(path.join(tools, 'openchamber-web.tgz'))).toBe(false);
96
+ expect(fs.readFileSync(path.join(tools, 'npm-args.txt'), 'utf8').trim()).toBe(`install --ignore-scripts --no-audit --no-fund --cache ${path.join(staging, 'npm-cache')}`);
97
+ // The one install script that runs: the OpenCode launcher needs its binary linked.
98
+ expect(fs.readFileSync(path.join(tools, 'node_modules', '@opencode/cli', 'postinstall-ran'), 'utf8')).toBe('yes');
99
+ expect(fs.readFileSync(path.join(tools, '.filled'), 'utf8')).toBe(KEY);
100
+ // The marker arrives through a rename, so a marker that exists is never half written.
101
+ expect(fs.existsSync(path.join(tools, '.filled.new'))).toBe(false);
102
+ });
103
+
104
+ it('writes no marker when npm fails', async () => {
105
+ fs.writeFileSync(path.join(tools, 'fail-npm'), '');
106
+ const result = await fill(buildFillInput({ key: KEY, packageJson: '{}' }));
107
+
108
+ expect(result.code).toBe(1);
109
+ expect(result.stderr).toContain('fill failed: npm install ended with 3');
110
+ expect(fs.existsSync(path.join(tools, '.filled'))).toBe(false);
111
+ });
112
+
113
+ it('writes no marker when a needed package is missing after the install', async () => {
114
+ fs.writeFileSync(path.join(tools, 'skip-plugin'), '');
115
+ const result = await fill(buildFillInput({ key: KEY, packageJson: '{}' }));
116
+
117
+ expect(result.code).toBe(1);
118
+ expect(result.stderr).toContain('node_modules/@opencode/plugin/package.json is missing');
119
+ expect(fs.existsSync(path.join(tools, '.filled'))).toBe(false);
120
+ });
121
+
122
+ it.each(['../escaped', 'sub/file', '/etc/passwd', '.filled', ''])('refuses the file name %j and writes nothing', async (name) => {
123
+ const result = await fill(rawInput({ key: KEY, files: [{ name, bytes: 1 }] }, 'x'));
124
+
125
+ expect(result.code).toBe(1);
126
+ expect(result.stderr).toContain('a file name must be a plain name');
127
+ expect(fs.readdirSync(tools)).toEqual([]);
128
+ expect(fs.existsSync(path.join(root, 'escaped'))).toBe(false);
129
+ });
130
+
131
+ it.each([
132
+ ['a length past the end of the input', { key: KEY, files: [{ name: 'a.tgz', bytes: 50 }] }, 'does not fit the input'],
133
+ ['a negative length', { key: KEY, files: [{ name: 'a.tgz', bytes: -1 }] }, 'does not fit the input'],
134
+ ['bytes after the last file', { key: KEY, files: [{ name: 'a.tgz', bytes: 1 }] }, 'longer than the header says'],
135
+ ['a bad key', { key: '../x', files: [] }, 'needs a key and a file list'],
136
+ ])('refuses %s', async (title, header, message) => {
137
+ const result = await fill(rawInput(header, 'xyz'));
138
+
139
+ expect(result.code).toBe(1);
140
+ expect(result.stderr).toContain(message);
141
+ expect(fs.existsSync(path.join(tools, '.filled'))).toBe(false);
142
+ });
143
+
144
+ it('refuses input without a header', async () => {
145
+ expect((await fill('no newline here')).stderr).toContain('no header line');
146
+ expect((await fill('not json\n')).stderr).toContain('not JSON');
147
+ });
148
+ });
@@ -0,0 +1,49 @@
1
+ // Packs the local `web` and `sdk` packages of a checkout into tarballs, for the tools
2
+ // volume of a development build. Nothing from the host's node_modules travels: native
3
+ // packages are per platform, so the filler installs every dependency itself, for Linux.
4
+ //
5
+ // Measured with bun 1.4.2:
6
+ // - `bun pm pack` runs the package's `prepack` script. For `web` that builds the built-in extensions.
7
+ // - It rewrites `workspace:*` to the version of the local package.
8
+ // - With `--destination` it writes `<name>-<version>.tgz` there. `--filename` cannot be combined with it.
9
+ // - With `--quiet` the last line of stdout is the path of the tarball. Script output comes before it.
10
+ // `vite build` is not needed: a space runs the server with `--api-only`.
11
+
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { SpaceError } from './errors.js';
16
+
17
+ const STEP_TIMEOUT_MS = 5 * 60_000;
18
+
19
+ const CHECKOUT_ROOT = fileURLToPath(new URL('../../../../../', import.meta.url));
20
+
21
+ /**
22
+ * Resolves `{ webTarballPath, sdkTarballPath }` inside `outputDirectory`, for createPackedToolsSource.
23
+ * Needs the workspace dependencies installed, because the sdk is compiled first.
24
+ */
25
+ export async function packLocalTools({ runCommand, bunPath = 'bun', checkoutRoot = CHECKOUT_ROOT, outputDirectory }) {
26
+ const step = async (what, packageName, args) => {
27
+ const cwd = path.join(checkoutRoot, 'packages', packageName);
28
+ const result = await runCommand(bunPath, args, { cwd, timeoutMs: STEP_TIMEOUT_MS });
29
+ if (result.code !== 0) {
30
+ throw new SpaceError('tools_pack_failed', `Could not ${what} in ${cwd}: ${result.stderr.trim() || `exit code ${result.code}`}. Run "bun install" in the checkout, then try again.`);
31
+ }
32
+ return result.stdout;
33
+ };
34
+
35
+ const pack = async (packageName) => {
36
+ const stdout = await step(`pack the ${packageName} package`, packageName, ['pm', 'pack', '--destination', outputDirectory, '--quiet']);
37
+ const tarball = stdout.split('\n').map((line) => line.trim()).filter(Boolean).pop() ?? '';
38
+ if (!tarball.endsWith('.tgz')) {
39
+ throw new SpaceError('tools_pack_failed', `bun did not say where it wrote the ${packageName} tarball`);
40
+ }
41
+ return path.resolve(outputDirectory, tarball);
42
+ };
43
+
44
+ // The sdk ships its compiled `dist`, and nothing in its pack step builds it.
45
+ await step('build the sdk package', 'sdk', ['run', 'build']);
46
+ const sdkTarballPath = await pack('sdk');
47
+ const webTarballPath = await pack('web');
48
+ return { webTarballPath, sdkTarballPath };
49
+ }