@vantran-se/openchamber-web 2.0.4 → 2.0.6

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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +4 -4
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -125,9 +125,12 @@ export const registerSkillRoutes = (app, dependencies) => {
125
125
  return { scope: SKILL_SCOPE.USER, source };
126
126
  };
127
127
 
128
+ // Returns null when OpenCode's list could not be read (not running, error,
129
+ // timeout, malformed payload) so callers can tell a failed fetch from a
130
+ // genuinely empty list.
128
131
  const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
129
132
  if (!getOpenCodePort()) {
130
- return [];
133
+ return null;
131
134
  }
132
135
 
133
136
  try {
@@ -145,13 +148,19 @@ export const registerSkillRoutes = (app, dependencies) => {
145
148
  const response = await client.skill.list();
146
149
  const payload = response?.data;
147
150
  if (!Array.isArray(payload)) {
148
- return [];
151
+ return null;
149
152
  }
150
153
 
151
154
  return payload
152
155
  .map((item) => {
153
156
  const name = typeof item?.name === 'string' ? item.name.trim() : '';
154
- const location = typeof item?.location === 'string' ? item.location : '';
157
+ // OpenCode v1's skill payload used `location`; v2 renamed the field
158
+ // to `path`. Accept both, or the whole authoritative list is dropped
159
+ // and the panel falls back to the (smaller) local disk scan.
160
+ const rawLocation = typeof item?.path === 'string' ? item.path : (typeof item?.location === 'string' ? item.location : '');
161
+ // v1 marked built-in skills with `<built-in>`; v2 gives them a synthetic
162
+ // `/builtin/<id>.md` path. Normalize so they stay read-only in the panel.
163
+ const location = rawLocation.startsWith('/builtin/') ? '<built-in>' : rawLocation;
155
164
  const description = typeof item?.description === 'string' ? item.description : '';
156
165
  const content = typeof item?.content === 'string' ? item.content : '';
157
166
  if (!name || !location) {
@@ -183,7 +192,7 @@ export const registerSkillRoutes = (app, dependencies) => {
183
192
  .filter(Boolean);
184
193
  } catch (error) {
185
194
  console.error('Failed to list OpenCode skills:', error);
186
- return [];
195
+ return null;
187
196
  }
188
197
  };
189
198
 
@@ -244,7 +253,7 @@ export const registerSkillRoutes = (app, dependencies) => {
244
253
  }
245
254
  const openCodeSkills = await fetchOpenCodeDiscoveredSkills(directory);
246
255
  const localSkills = discoverSkills(directory);
247
- const skills = mergeDiscoveredSkills(openCodeSkills, localSkills);
256
+ const skills = mergeDiscoveredSkills(openCodeSkills ?? [], localSkills);
248
257
 
249
258
  const enrichedSkills = skills.map((skill) => {
250
259
  const sources = getSkillSources(skill.name, directory, skill);
@@ -268,7 +277,7 @@ export const registerSkillRoutes = (app, dependencies) => {
268
277
  // OpenCode's own skill-list endpoint is not usable for this: on 1.18.14
269
278
  // it returns only global and builtin skills, omitting the project
270
279
  // `.agents`/`.claude` skills the agent demonstrably has.
271
- res.json({
280
+ const body = {
272
281
  skills: enrichedSkills,
273
282
  externalSkills: {
274
283
  // `OPENCODE_DISABLE_CLAUDE_CODE` is the broad switch; the specific
@@ -277,7 +286,14 @@ export const registerSkillRoutes = (app, dependencies) => {
277
286
  || isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS),
278
287
  allDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_EXTERNAL_SKILLS),
279
288
  },
280
- });
289
+ };
290
+ // The list is only the disk scan when OpenCode's own list failed:
291
+ // built-in skills and anything OpenCode finds only through its config
292
+ // are missing, so the client must not treat this as complete.
293
+ if (openCodeSkills === null) {
294
+ body.openCodeSkillsUnavailable = true;
295
+ }
296
+ res.json(body);
281
297
  } catch (error) {
282
298
  console.error('Failed to list skills:', error);
283
299
  res.status(500).json({ error: 'Failed to list skills' });
@@ -366,7 +382,7 @@ export const registerSkillRoutes = (app, dependencies) => {
366
382
  }
367
383
 
368
384
  const resolvedDiscovered = mergeDiscoveredSkills(
369
- await fetchOpenCodeDiscoveredSkills(directory),
385
+ (await fetchOpenCodeDiscoveredSkills(directory)) ?? [],
370
386
  discoverSkills(directory),
371
387
  );
372
388
  const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s]));
@@ -538,7 +554,7 @@ export const registerSkillRoutes = (app, dependencies) => {
538
554
  if (error) {
539
555
  return res.status(400).json({ error });
540
556
  }
541
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
557
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
542
558
  .find((skill) => skill.name === skillName) || null;
543
559
  const sources = getSkillSources(skillName, directory, discoveredSkill);
544
560
 
@@ -567,7 +583,7 @@ export const registerSkillRoutes = (app, dependencies) => {
567
583
  return res.status(400).json({ error });
568
584
  }
569
585
 
570
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
586
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
571
587
  .find((skill) => skill.name === skillName) || null;
572
588
  const sources = getSkillSources(skillName, directory, discoveredSkill);
573
589
  if (!sources.md.exists || !sources.md.dir) {
@@ -661,7 +677,7 @@ export const registerSkillRoutes = (app, dependencies) => {
661
677
  return res.status(400).json({ error });
662
678
  }
663
679
 
664
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
680
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
665
681
  .find((skill) => skill.name === skillName) || null;
666
682
  const sources = getSkillSources(skillName, directory, discoveredSkill);
667
683
  if (!sources.md.exists || !sources.md.dir) {
@@ -695,7 +711,7 @@ export const registerSkillRoutes = (app, dependencies) => {
695
711
  return res.status(400).json({ error });
696
712
  }
697
713
 
698
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
714
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
699
715
  .find((skill) => skill.name === skillName) || null;
700
716
  const sources = getSkillSources(skillName, directory, discoveredSkill);
701
717
  if (!sources.md.exists || !sources.md.dir) {
@@ -28,7 +28,7 @@ const createTempProject = () => {
28
28
  return projectRoot;
29
29
  };
30
30
 
31
- const startSkillsApp = ({ projectRoot }) => {
31
+ const startSkillsApp = ({ projectRoot, overrides = {} }) => {
32
32
  const app = express();
33
33
  app.use(express.json());
34
34
 
@@ -76,6 +76,7 @@ const startSkillsApp = ({ projectRoot }) => {
76
76
  fetchGitHubRepoMetas: async () => ({}),
77
77
  getProfiles: () => [],
78
78
  getProfile: () => null,
79
+ ...overrides,
79
80
  });
80
81
 
81
82
  const server = app.listen(0);
@@ -213,4 +214,123 @@ describe('skill-routes directory soft fallback', () => {
213
214
  });
214
215
  }
215
216
  });
217
+
218
+ it('lists OpenCode skills reported with the v2 `path` field and keeps the v1 `location` fallback', async () => {
219
+ projectRoot = createTempProject();
220
+
221
+ // OpenCode v2's skill payload renamed `location` to `path`. The route maps
222
+ // the fetch result to { name, path, ... }; with the old field only, the
223
+ // whole authoritative list was dropped and the panel fell back to the
224
+ // smaller local disk scan. Serve a v2-style payload from a stub.
225
+ const stub = express();
226
+ stub.get('/api/skill', (_req, res) => {
227
+ res.json({
228
+ location: { directory: projectRoot },
229
+ data: [
230
+ {
231
+ id: 'v2-path-skill',
232
+ name: 'v2-path-skill',
233
+ path: path.join(projectRoot, '.agents', 'skills', 'v2-path-skill'),
234
+ description: 'Delivered with the v2 path field',
235
+ },
236
+ {
237
+ id: 'v1-location-skill',
238
+ name: 'v1-location-skill',
239
+ location: path.join(projectRoot, '.agents', 'skills', 'v1-location-skill'),
240
+ description: 'Delivered with the legacy location field',
241
+ },
242
+ {
243
+ id: 'opencode',
244
+ name: 'OpenCode',
245
+ path: '/builtin/opencode.md',
246
+ description: 'v2 built-in skill with a synthetic path',
247
+ },
248
+ {
249
+ id: 'unlocated-skill',
250
+ name: 'unlocated-skill',
251
+ description: 'Has neither field; must be dropped',
252
+ },
253
+ ],
254
+ });
255
+ });
256
+ const stubServer = await new Promise((resolve) => {
257
+ const server = stub.listen(0, () => resolve(server));
258
+ });
259
+ const stubPort = stubServer.address().port;
260
+
261
+ try {
262
+ appHandle = startSkillsApp({
263
+ projectRoot,
264
+ overrides: {
265
+ buildOpenCodeUrl: () => `http://127.0.0.1:${stubPort}/`,
266
+ getOpenCodePort: () => stubPort,
267
+ getOpenCodeAuthHeaders: () => ({}),
268
+ },
269
+ });
270
+
271
+ const listResponse = await fetch(
272
+ `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`,
273
+ );
274
+ expect(listResponse.status).toBe(200);
275
+ const payload = await listResponse.json();
276
+ const byName = new Map(payload.skills.map((skill) => [skill.name, skill]));
277
+
278
+ expect(byName.has('v2-path-skill')).toBe(true);
279
+ expect(byName.get('v2-path-skill').path).toContain('v2-path-skill');
280
+
281
+ expect(byName.has('v1-location-skill')).toBe(true);
282
+ expect(byName.get('v1-location-skill').path).toContain('v1-location-skill');
283
+
284
+ expect(byName.get('OpenCode')?.path).toBe('<built-in>');
285
+ expect(byName.get('OpenCode')?.renamable).toBe(false);
286
+
287
+ expect(byName.has('unlocated-skill')).toBe(false);
288
+ } finally {
289
+ stubServer.close();
290
+ }
291
+ });
292
+ it('flags the list as partial when OpenCode skill list fails, and not when it succeeds', async () => {
293
+ projectRoot = createTempProject();
294
+ fs.mkdirSync(path.join(projectRoot, '.agents', 'skills', 'disk-skill'), { recursive: true });
295
+ fs.writeFileSync(
296
+ path.join(projectRoot, '.agents', 'skills', 'disk-skill', 'SKILL.md'),
297
+ '---\nname: disk-skill\ndescription: On disk\n---\nBody\n',
298
+ );
299
+
300
+ let failing = true;
301
+ const stub = express();
302
+ stub.get('/api/skill', (_req, res) => {
303
+ if (failing) {
304
+ res.status(500).json({ error: 'boom' });
305
+ return;
306
+ }
307
+ res.json({ data: [] });
308
+ });
309
+ const stubServer = await new Promise((resolve) => {
310
+ const server = stub.listen(0, () => resolve(server));
311
+ });
312
+ const stubPort = stubServer.address().port;
313
+
314
+ try {
315
+ appHandle = startSkillsApp({
316
+ projectRoot,
317
+ overrides: {
318
+ buildOpenCodeUrl: () => `http://127.0.0.1:${stubPort}/`,
319
+ getOpenCodePort: () => stubPort,
320
+ },
321
+ });
322
+ const url = `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`;
323
+
324
+ const failed = await (await fetch(url)).json();
325
+ expect(failed.openCodeSkillsUnavailable).toBe(true);
326
+ expect(failed.skills.map((skill) => skill.name)).toContain('disk-skill');
327
+
328
+ failing = false;
329
+ const complete = await (await fetch(url)).json();
330
+ expect(complete.openCodeSkillsUnavailable).toBeUndefined();
331
+ expect(complete.skills.map((skill) => skill.name)).toContain('disk-skill');
332
+ } finally {
333
+ stubServer.close();
334
+ }
335
+ });
216
336
  });
@@ -1,23 +1,9 @@
1
- // TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
2
- // FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
3
- // Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
4
- // binary with the broken ARM64 build. Remove when the upstream issue is resolved.
5
- const isWindowsArm64 = () => process.platform === 'win32' && process.arch === 'arm64';
6
-
7
1
  export const resolveOpenCodeUpgradeCapability = ({
8
2
  isExternal,
9
3
  hasManagedProcess,
10
4
  activeBinary,
11
5
  isBundledBinary,
12
6
  }) => {
13
- if (isWindowsArm64()) {
14
- return {
15
- supported: false,
16
- manager: 'openchamber',
17
- reason: 'windows-arm64-workaround',
18
- };
19
- }
20
-
21
7
  if (isExternal) {
22
8
  return {
23
9
  supported: false,
@@ -21,14 +21,16 @@
21
21
  * seen v2 activity. Every visited session gets its `session_message` rows
22
22
  * deleted and replaced by the V1 transform, so a migrated session that was
23
23
  * continued in v2 would lose that conversation.
24
- * 3. A session deleted in v2 comes back. A v2 delete removes the `session_v2`
25
- * row but leaves the legacy `session` row behind, so the loop imports it
26
- * again. Decided by the maintainer (2026-09-22): people run 1.x and 2.x
27
- * side by side while switching, and OpenChamber's own time-based cleanup
28
- * may have deleted sessions the user never touched, so importing again is
29
- * better than importing nothing. Deleting again is cheap; a lost 1.x
30
- * session is not. This holds until OpenCode ships an import route that
31
- * takes an explicit list of sessions.
24
+ * 3. Only 1.x activity since the last completed import triggers a new one.
25
+ * A v2 delete removes the `session_v2` row but leaves the legacy `session`
26
+ * row behind, so "missing from v2" alone would bring deleted sessions back
27
+ * on every start. A legacy session counts as missing only when its
28
+ * `time_updated` is newer than the completion stamp OpenCode writes on
29
+ * `migration.v1-v2`: only OpenCode 1.x writes that table, so an unchanged
30
+ * table means nobody ran 1.x since and the top-up skips. Decided by the
31
+ * maintainer (2026-09-24). When 1.x was used again, OpenCode still walks
32
+ * every id below the cursor, so a deleted session sorting below a fresh one
33
+ * comes back with it; that needs an import route taking an explicit list.
32
34
  *
33
35
  * The loop OpenCode runs is `SELECT id FROM session WHERE id < cursor ORDER BY
34
36
  * id DESC LIMIT 1`, one session at a time, so the cursor has to be strictly
@@ -196,11 +198,15 @@ const findRevisitedSessionsWithV2Activity = (db, cursor, completedAt) =>
196
198
  return parsed.success ? [parsed.data.id] : [];
197
199
  });
198
200
 
199
- /** A legacy session with no `session_v2` twin, whether never imported or deleted in v2 (see rule 3). */
200
- const MISSING_CONDITION = 'id NOT IN (SELECT id FROM session_v2)';
201
+ /**
202
+ * A legacy session with no `session_v2` twin that 1.x touched after the last
203
+ * completed import (see rule 3). Bound parameter: the completion timestamp.
204
+ */
205
+ const MISSING_CONDITION = 'id NOT IN (SELECT id FROM session_v2) AND time_updated > ?';
201
206
 
202
- const countMissingSessions = (db) =>
203
- firstRow(countRowSchema, db.all(`SELECT COUNT(*) AS value FROM session WHERE ${MISSING_CONDITION}`))?.value ?? 0;
207
+ const countMissingSessions = (db, completedAt) =>
208
+ firstRow(countRowSchema, db.all(`SELECT COUNT(*) AS value FROM session WHERE ${MISSING_CONDITION}`, [completedAt]))
209
+ ?.value ?? 0;
204
210
 
205
211
  const countRevisitedSessions = (db, cursor) =>
206
212
  firstRow(
@@ -244,12 +250,12 @@ export const topUpV1Migration = (options = {}) => {
244
250
  const migration = readCompletedMigration(db);
245
251
  if (!migration) return outcome('skipped', 'migration-not-completed');
246
252
 
247
- const missingCount = countMissingSessions(db);
253
+ const missingCount = countMissingSessions(db, migration.completedAt);
248
254
  if (missingCount === 0) return outcome('skipped', 'nothing-missing');
249
255
 
250
256
  const maxMissing = firstRow(
251
257
  nullableIdRowSchema,
252
- db.all(`SELECT MAX(id) AS id FROM session WHERE ${MISSING_CONDITION}`),
258
+ db.all(`SELECT MAX(id) AS id FROM session WHERE ${MISSING_CONDITION}`, [migration.completedAt]),
253
259
  )?.id;
254
260
  if (!maxMissing) return outcome('skipped', 'nothing-missing');
255
261
 
@@ -271,7 +277,7 @@ export const topUpV1Migration = (options = {}) => {
271
277
  MIGRATION_STATE_KEY,
272
278
  ]);
273
279
  logger.log(
274
- `[OpenCode] Scheduled the import of ${missingCount} OpenCode 1.x session(s) created after the v2 migration; ` +
280
+ `[OpenCode] Scheduled the import of ${missingCount} OpenCode 1.x session(s) changed since the last v2 import; ` +
275
281
  `OpenCode will re-import ${revisitedCount} untouched session(s) on the way.`,
276
282
  );
277
283
  return outcome('scheduled', undefined, missingCount, revisitedCount);
@@ -226,13 +226,21 @@ describe.skipIf(!sqlite)('topUpV1Migration', () => {
226
226
  });
227
227
  });
228
228
 
229
- it('imports a session deleted in v2 again rather than leaving newer 1.x sessions behind', () => {
230
- // ses_a was walked by the migration and deleted in v2 (only the legacy row
231
- // is left); ses_d was created after. The maintainer chose to bring ses_a
232
- // back over losing ses_d: deleting again is cheap, a lost session is not.
229
+ it('does not bring back a session deleted in v2 when 1.x was not used since', () => {
230
+ // ses_a was imported and then deleted in v2: only its legacy row is left,
231
+ // untouched since the last completed import.
233
232
  createDatabase();
234
- seed({ v1: [{ id: 'ses_a', timeCreated: COMPLETED_AT - 1 }, 'ses_d'] });
235
- expect(run()).toEqual({ status: 'scheduled', missing: 2, revisited: 0 });
233
+ seed({ v1: [{ id: 'ses_a', timeCreated: COMPLETED_AT - 1 }] });
234
+ expect(run()).toEqual({ status: 'skipped', missing: 0, revisited: 0, reason: 'nothing-missing' });
235
+ expect(readMigrationRow().state).toEqual({ phase: 'completed' });
236
+ });
237
+
238
+ it('imports only sessions 1.x changed since the last import, cursor included', () => {
239
+ // ses_z (deleted in v2, old) sorts above ses_d (fresh from 1.x): the
240
+ // cursor must stop at ses_d so ses_z stays gone.
241
+ createDatabase();
242
+ seed({ v1: [{ id: 'ses_z', timeCreated: COMPLETED_AT - 1 }, 'ses_d'] });
243
+ expect(run()).toEqual({ status: 'scheduled', missing: 1, revisited: 0 });
236
244
  expect(readMigrationRow().state).toEqual({ phase: 'sessions', cursor: computeResumeCursor('ses_d') });
237
245
  });
238
246
 
@@ -4,5 +4,6 @@ export function installOpenCodeV2(options?: {
4
4
  env?: NodeJS.ProcessEnv;
5
5
  fetchImpl?: typeof fetch;
6
6
  platform?: NodeJS.Platform;
7
+ arch?: NodeJS.Architecture;
7
8
  tarCommand?: string;
8
9
  }): Promise<string>;
@@ -18,10 +18,10 @@ const packageDistSchema = z.object({
18
18
  });
19
19
 
20
20
  // OpenCode's installer is a bash script, so Windows installs the npm platform
21
- // package that script downloads. x64-baseline serves arm64 too: the native arm64
22
- // build fails to load (https://github.com/anomalyco/opencode/issues/19130), the
23
- // same workaround the desktop bundle uses in packages/electron/scripts/prepare-opencode-cli.mjs.
24
- const WINDOWS_PACKAGE = '@opencode/cli-windows-x64-baseline';
21
+ // package that script downloads, the same one the desktop bundle picks in
22
+ // packages/electron/scripts/prepare-opencode-cli.mjs. x64 takes the baseline
23
+ // build so hosts without AVX2 still run it.
24
+ const windowsPackage = (arch) => (arch === 'arm64' ? '@opencode/cli-windows-arm64' : '@opencode/cli-windows-x64-baseline');
25
25
 
26
26
  export const supportsOpenCodeV2Install = (platform = process.platform) =>
27
27
  (platform === 'darwin' || platform === 'linux' || platform === 'win32') && (process.arch === 'x64' || process.arch === 'arm64');
@@ -72,8 +72,8 @@ const prepareInstallerScript = async ({ version, workDirectory, env, fetchImpl }
72
72
  * Downloads the Windows platform package, checks it against the integrity npm
73
73
  * publishes for it, and unpacks it. Returns the step that replaces the binary.
74
74
  */
75
- const prepareWindowsBinary = async ({ version, directory, workDirectory, fetchImpl, tarCommand }) => {
76
- const metadataResponse = await fetchImpl(`${NPM_REGISTRY}/${WINDOWS_PACKAGE.replace('/', '%2F')}/${version}`, { signal: AbortSignal.timeout(15_000) });
75
+ const prepareWindowsBinary = async ({ version, arch, directory, workDirectory, fetchImpl, tarCommand }) => {
76
+ const metadataResponse = await fetchImpl(`${NPM_REGISTRY}/${windowsPackage(arch).replace('/', '%2F')}/${version}`, { signal: AbortSignal.timeout(15_000) });
77
77
  if (!metadataResponse.ok) throw new Error('Could not resolve the OpenCode v2 package.');
78
78
  const { dist } = packageDistSchema.parse(await metadataResponse.json());
79
79
  if (new URL(dist.tarball).origin !== NPM_REGISTRY) throw new Error('The OpenCode v2 package is not served by the npm registry.');
@@ -100,6 +100,7 @@ export const installOpenCodeV2 = async ({
100
100
  env = process.env,
101
101
  fetchImpl = fetch,
102
102
  platform = process.platform,
103
+ arch = process.arch,
103
104
  // Windows' own bsdtar. Git Bash's GNU tar on PATH reads `C:\...` as a remote host.
104
105
  tarCommand = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'),
105
106
  } = {}) => {
@@ -119,7 +120,7 @@ export const installOpenCodeV2 = async ({
119
120
  if (!releaseResponse.ok) throw new Error('Could not resolve the OpenCode v2 release.');
120
121
  const { version } = releaseSchema.parse(await releaseResponse.json());
121
122
  const replaceBinary = windows
122
- ? await prepareWindowsBinary({ version, directory, workDirectory: lock, fetchImpl, tarCommand })
123
+ ? await prepareWindowsBinary({ version, arch, directory, workDirectory: lock, fetchImpl, tarCommand })
123
124
  : await prepareInstallerScript({ version, workDirectory: lock, env, fetchImpl });
124
125
  for (const name of binaryNames) {
125
126
  const target = path.join(directory, name);
@@ -63,7 +63,7 @@ chmod 755 ${quote(binary)}
63
63
  });
64
64
 
65
65
  describe('on Windows', () => {
66
- const TARBALL = 'https://registry.npmjs.org/@opencode/cli-windows-x64-baseline/-/cli-windows-x64-baseline-2.0.15.tgz';
66
+ const tarballOf = (name) => `https://registry.npmjs.org/@opencode/${name}/-/${name}-2.0.15.tgz`;
67
67
  let exe;
68
68
  const integrityOf = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
69
69
  // The platform package keeps its binary at package/bin/opencode.exe. Here it is a
@@ -77,14 +77,17 @@ chmod 755 ${quote(binary)}
77
77
  await fs.rm(root, { recursive: true, force: true });
78
78
  return bytes;
79
79
  };
80
- const runWindows = (archive, integrity = integrityOf(archive)) => installOpenCodeV2({
80
+ const PACKAGE_BY_ARCH = { x64: 'cli-windows-x64-baseline', arm64: 'cli-windows-arm64' };
81
+ const runWindows = (archive, integrity = integrityOf(archive), arch = 'x64') => installOpenCodeV2({
81
82
  homeDirectory,
82
83
  platform: 'win32',
84
+ arch,
83
85
  tarCommand: 'tar',
84
86
  fetchImpl: async (url) => {
87
+ const name = PACKAGE_BY_ARCH[arch];
85
88
  if (url.endsWith('/@opencode%2Fcli/latest')) return Response.json({ version: '2.0.15' });
86
- if (url.endsWith('/@opencode%2Fcli-windows-x64-baseline/2.0.15')) return Response.json({ dist: { tarball: TARBALL, integrity } });
87
- if (url === TARBALL) return new Response(archive);
89
+ if (url.endsWith(`/@opencode%2F${name}/2.0.15`)) return Response.json({ dist: { tarball: tarballOf(name), integrity } });
90
+ if (url === tarballOf(name)) return new Response(archive);
88
91
  return new Response(null, { status: 404 });
89
92
  },
90
93
  });
@@ -101,6 +104,12 @@ chmod 755 ${quote(binary)}
101
104
  await expect(fs.stat(path.join(path.dirname(exe), '.openchamber-install'))).rejects.toMatchObject({ code: 'ENOENT' });
102
105
  });
103
106
 
107
+ it('installs the native arm64 package on Windows ARM64', async () => {
108
+ const archive = await pack('2.0.15');
109
+ expect(await runWindows(archive, integrityOf(archive), 'arm64')).toBe(exe);
110
+ expect(await readOpenCodeCliVersion({ binary: exe, args: [] })).toBe('2.0.15');
111
+ });
112
+
104
113
  it('rejects a package that does not match its published integrity before touching the binary', async () => {
105
114
  const archive = await pack('2.0.15');
106
115
  await expect(runWindows(archive, integrityOf(Buffer.from('other')))).rejects.toThrow('integrity');
@@ -47,13 +47,15 @@ export const createOpenCodeWatcherRuntime = (deps) => {
47
47
  const signal = abortController.signal;
48
48
 
49
49
  if (globalEventHub) {
50
+ // The events of isolated spaces feed this watcher too, so live status, unread marks and
51
+ // notifications work for a space's sessions as for the host's.
50
52
  unsubscribeEvent = globalEventHub.subscribeEvent((event) => {
51
53
  const payload = unwrapGlobalEventPayload(event.payload);
52
54
  if (!payload || typeof payload !== 'object') {
53
55
  return;
54
56
  }
55
57
  emitTranslated(payload);
56
- });
58
+ }, { spaces: true });
57
59
  unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
58
60
  if (signal.aborted) {
59
61
  return;
@@ -9,7 +9,7 @@ import {
9
9
  readConfigLayers,
10
10
  writeConfig,
11
11
  } from './shared.js';
12
- import { findWebSearchProjectOverride, writeWebSearchSelection } from './config-v2.js';
12
+ import { findWebSearchProjectOverride, writeWarmingEnabled, writeWebSearchSelection } from './config-v2.js';
13
13
 
14
14
  /**
15
15
  * Writes the `websearch` choice into the config OpenCode reads last among the
@@ -26,6 +26,18 @@ export function setWebSearchSelection(selection) {
26
26
  return { path: target.path, changed };
27
27
  }
28
28
 
29
+ /**
30
+ * Turns session warming on or off in the same file the web search choice goes
31
+ * to; OpenCode watches it, so the change applies without a restart.
32
+ */
33
+ export function setWarmingEnabled(enabled) {
34
+ const layers = readConfigLayers(null);
35
+ const target = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
36
+ const changed = writeWarmingEnabled(target.config, enabled);
37
+ if (changed) writeConfig(target.config, target.path);
38
+ return { path: target.path, changed };
39
+ }
40
+
29
41
  /**
30
42
  * Where the effective `websearch` value for `directory` comes from, as far as
31
43
  * a Settings write is concerned: `projectPath` is the project config that
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  export const providerId = 'kimi-for-coding';
14
14
  export const providerName = 'Kimi for Coding';
15
- const aliases = ['kimi-for-coding', 'kimi'];
15
+ const aliases = ['kimi-for-coding', 'kimi', 'kimi-code-plan-global'];
16
16
 
17
17
  // Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
18
18
  // blocks report `remaining` instead. Neither field is guaranteed present, so
@@ -10,10 +10,14 @@ const isAllowedSsePath = (pathname) => {
10
10
  || pathname === '/api/notifications/stream';
11
11
  };
12
12
 
13
+ // The same three sockets of an isolated space, under `/api/spaces/<id>/`, matched by shape.
14
+ const SPACE_WS_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/(?:terminal\/ws|event\/ws|global\/event\/ws)$/;
15
+
13
16
  const isAllowedWebSocketPath = (pathname) => {
14
17
  return pathname === '/api/event/ws'
15
18
  || pathname === '/api/global/event/ws'
16
- || pathname === '/api/terminal/ws';
19
+ || pathname === '/api/terminal/ws'
20
+ || SPACE_WS_PATH.test(pathname);
17
21
  };
18
22
 
19
23
  const normalizeBaseUrl = (value) => {
@@ -228,6 +228,35 @@ describe('realtime proxy', () => {
228
228
  }
229
229
  });
230
230
 
231
+ it('proxies the sockets of an isolated space by path shape, and refuses another path under the prefix', async () => {
232
+ const seen = [];
233
+ const upstreamServer = http.createServer();
234
+ const upstreamWs = new WebSocketServer({ server: upstreamServer });
235
+ upstreamWs.on('connection', (socket, request) => {
236
+ seen.push(request.url);
237
+ socket.send('ready');
238
+ });
239
+ const upstreamOrigin = await listen(upstreamServer);
240
+ const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin });
241
+ const attempt = (path) => new Promise((resolve, reject) => {
242
+ const client = new WebSocket(buildRealtimeProxyWsUrl(origin, `${upstreamOrigin.replace(/^http:/, 'ws:')}${path}`), { headers: { Origin: 'openchamber-ui://app' } });
243
+ client.once('message', (data) => { client.close(); resolve({ message: data.toString() }); });
244
+ client.once('close', (code) => resolve({ code }));
245
+ client.once('error', reject);
246
+ });
247
+
248
+ try {
249
+ expect(await attempt('/api/spaces/a1b2c3d4e5f6/terminal/ws')).toEqual({ message: 'ready' });
250
+ expect(await attempt('/api/spaces/a1b2c3d4e5f6/event/ws?directory=%2Fspaces%2Fa1b2c3d4e5f6%2Frepo')).toEqual({ message: 'ready' });
251
+ expect(await attempt('/api/spaces/a1b2c3d4e5f6/dictation/ws')).toEqual({ code: 1008 });
252
+ expect(await attempt('/api/spaces/A1B2C3D4E5F6/terminal/ws')).toEqual({ code: 1008 });
253
+ expect(seen).toEqual(['/api/spaces/a1b2c3d4e5f6/terminal/ws', '/api/spaces/a1b2c3d4e5f6/event/ws?directory=%2Fspaces%2Fa1b2c3d4e5f6%2Frepo']);
254
+ upstreamWs.close();
255
+ } finally {
256
+ runtime.stop();
257
+ }
258
+ });
259
+
231
260
  it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => {
232
261
  const upstreamServer = http.createServer();
233
262
  const upstreamWs = new WebSocketServer({ server: upstreamServer });
@@ -37,7 +37,10 @@ const ALLOWED_WS_PATHS = new Set([
37
37
  // Extension surfaces carry the extension id in the path, so they are matched
38
38
  // by shape rather than listed.
39
39
  const GUEST_SURFACE_WS_PATH = /^\/api\/guests\/[a-z][a-z0-9-]*\/surface\/ws$/;
40
- export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname) || GUEST_SURFACE_WS_PATH.test(pathname);
40
+ // The sockets of an isolated space carry the space id in the path: the same four sockets,
41
+ // under `/api/spaces/<id>/`, matched by shape.
42
+ const SPACE_WS_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/(?:terminal\/ws|dev-tunnel|event\/ws|global\/event\/ws)$/;
43
+ export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname) || GUEST_SURFACE_WS_PATH.test(pathname) || SPACE_WS_PATH.test(pathname);
41
44
 
42
45
  // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
43
46
  // to the loopback origin. content-length is dropped too because the body is
@@ -153,6 +153,17 @@ describe('relay host WebSocket allowlist', () => {
153
153
  expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
154
154
  });
155
155
 
156
+ test('allows the sockets of an isolated space by path shape only', () => {
157
+ for (const socket of ['terminal/ws', 'dev-tunnel', 'event/ws', 'global/event/ws']) {
158
+ expect(isAllowedRelayWebSocketPath(`/api/spaces/a1b2c3d4e5f6/${socket}`)).toBe(true);
159
+ }
160
+ expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f6/dictation/ws')).toBe(false);
161
+ expect(isAllowedRelayWebSocketPath('/api/spaces/A1B2C3D4E5F6/terminal/ws')).toBe(false);
162
+ expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f/terminal/ws')).toBe(false);
163
+ expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f6/terminal/ws/x')).toBe(false);
164
+ expect(isAllowedRelayWebSocketPath('/api/spaces//terminal/ws')).toBe(false);
165
+ });
166
+
156
167
  test('allows an extension surface socket by path shape only', () => {
157
168
  expect(isAllowedRelayWebSocketPath('/api/guests/server-chrome/surface/ws')).toBe(true);
158
169
  expect(isAllowedRelayWebSocketPath('/api/guests/Server/surface/ws')).toBe(false);