@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
@@ -0,0 +1,345 @@
1
+ // The WebSocket forwarder against a stand-in for the server inside a space: an HTTP server with
2
+ // the host's real UI auth in front of a terminal-like echo socket, so the login, the cookie,
3
+ // the origin check and the refusal of a bad session are the real ones.
4
+
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import express from 'express';
13
+ import { WebSocket, WebSocketServer } from 'ws';
14
+ import { afterAll, afterEach, describe, expect, it } from 'vitest';
15
+
16
+ import { SpaceError } from './errors.js';
17
+ import { createSpaceDispatcher } from './dispatcher.js';
18
+ import { buildHandshake, closeFrame, createSpaceWebSocketForwarder, parseHandshakeAnswer } from './websocket.js';
19
+
20
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-space-ws-test-'));
21
+ process.env.OPENCHAMBER_DATA_DIR = dataDir;
22
+
23
+ const ID = 'a1b2c3d4e5f6';
24
+ const OTHER = '0f0f0f0f0f0f';
25
+ const TOKEN = 'tok_' + 'a'.repeat(40);
26
+
27
+ const listen = (server) => new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve(server)); });
28
+ const close = (server) => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
29
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
30
+
31
+ /** The stand-in inside: the host's UI auth, the terminal's upgrade rule, an echo socket that reports what it saw. */
32
+ const startInside = async () => {
33
+ const { createUiAuth } = await import('../ui-auth/ui-auth.js');
34
+ const auth = createUiAuth({ password: TOKEN, readSettingsFromDiskMigrated: async () => ({}) });
35
+ const state = { logins: 0, refuseNext: 0, upgrades: [], sockets: new Set() };
36
+ const app = express();
37
+ app.post('/auth/session', express.json(), (req, res) => { state.logins += 1; return auth.handleSessionCreate(req, res); });
38
+ const server = http.createServer(app);
39
+ const wsServer = new WebSocketServer({ noServer: true });
40
+ wsServer.on('connection', (socket, req) => {
41
+ state.sockets.add(socket);
42
+ socket.on('close', () => state.sockets.delete(socket));
43
+ socket.send(JSON.stringify({ hello: req.url, headers: req.headers }));
44
+ socket.on('message', (data, isBinary) => socket.send(data, { binary: isBinary }));
45
+ });
46
+ server.on('upgrade', (req, socket, head) => {
47
+ const { pathname, searchParams } = new URL(req.url, 'http://localhost');
48
+ const reject = (status, text) => { socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); socket.destroy(); };
49
+ void (async () => {
50
+ state.upgrades.push({ url: req.url, headers: req.headers });
51
+ if (state.refuseNext > 0) { state.refuseNext -= 1; reject(401, 'Unauthorized'); return; }
52
+ if (!await auth.ensureSessionToken(req, null)) { reject(401, 'Unauthorized'); return; }
53
+ // The origin rule of the server: the origin's host must be the request's host.
54
+ const origin = req.headers.origin ?? '';
55
+ if (!origin || new URL(origin).host !== req.headers.host) { reject(403, 'Invalid origin'); return; }
56
+ if (pathname === '/api/dev-tunnel' && searchParams.get('port') !== '3000') { reject(403, 'That port is not an available dev server'); return; }
57
+ if (!['/api/terminal/ws', '/api/dev-tunnel', '/api/event/ws', '/api/global/event/ws'].includes(pathname)) { reject(404, 'Not Found'); return; }
58
+ wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
59
+ })();
60
+ });
61
+ await listen(server);
62
+ return { server, port: server.address().port, state, stop: async () => { wsServer.close(); await close(server); } };
63
+ };
64
+
65
+ const transportTo = (inside) => {
66
+ const calls = { connect: 0 };
67
+ return {
68
+ calls,
69
+ ids: [ID],
70
+ listSpaceIds: async function () { return this.ids; },
71
+ connect: async function (spaceId) {
72
+ calls.connect += 1;
73
+ if (!this.ids.includes(spaceId)) throw new SpaceError('space_not_found', `Space ${spaceId} has no container`);
74
+ return net.connect({ host: '127.0.0.1', port: inside().port });
75
+ },
76
+ readToken: async () => TOKEN,
77
+ };
78
+ };
79
+
80
+ const logs = [];
81
+ const logger = { warn: (line) => logs.push(line) };
82
+
83
+ /** The host: a bare HTTP server whose only upgrade handler is the forwarder's. */
84
+ const startHost = async (forwarder) => {
85
+ const server = http.createServer((_req, res) => { res.statusCode = 404; res.end(); });
86
+ server.on('upgrade', forwarder.upgradeHandler);
87
+ await listen(server);
88
+ return { server, port: server.address().port, url: (suffix) => `ws://127.0.0.1:${server.address().port}${suffix}`, stop: () => close(server) };
89
+ };
90
+
91
+ /** Opens a socket and resolves the first message, or the HTTP refusal as `{ status, body }`. */
92
+ const open = (url, options = {}) => new Promise((resolve, reject) => {
93
+ const socket = new WebSocket(url, { ...options, headers: { origin: 'http://app.test', ...(options.headers ?? {}) } });
94
+ socket.on('unexpected-response', (_request, response) => {
95
+ const chunks = [];
96
+ response.on('data', (chunk) => chunks.push(chunk));
97
+ response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
98
+ });
99
+ socket.on('error', reject);
100
+ socket.once('message', (data) => resolve({ socket, first: JSON.parse(data.toString('utf8')) }));
101
+ });
102
+
103
+ const until = async (check, timeoutMs = 3_000) => {
104
+ const deadline = Date.now() + timeoutMs;
105
+ while (!check() && Date.now() < deadline) await sleep(25);
106
+ return check();
107
+ };
108
+
109
+ const waitForClose = (socket) => new Promise((resolve) => { socket.on('close', (code, reason) => resolve({ code, reason: reason.toString('utf8') })); });
110
+
111
+ describe('the handshake pieces', () => {
112
+ it('builds the request inside with the space session, the loopback origin and the client\'s socket headers, without the user\'s credentials', () => {
113
+ const request = buildHandshake({
114
+ innerPath: '/api/terminal/ws',
115
+ search: '?a=1',
116
+ requestHeaders: { host: 'app.test', origin: 'http://app.test', cookie: 'oc_ui_session_3000=user', authorization: 'Bearer user', upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'abc', 'sec-websocket-version': '13', 'sec-websocket-protocol': 'p1', 'x-forwarded-for': '1.2.3.4' },
117
+ cookie: 'oc_ui_session_27600=space',
118
+ });
119
+ const [line, ...rest] = request.split('\r\n');
120
+ expect(line).toBe('GET /api/terminal/ws?a=1 HTTP/1.1');
121
+ const headers = Object.fromEntries(rest.filter(Boolean).map((entry) => entry.split(': ')));
122
+ expect(headers).toMatchObject({ host: '127.0.0.1:27600', origin: 'http://127.0.0.1:27600', cookie: 'oc_ui_session_27600=space', upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'abc', 'sec-websocket-version': '13', 'sec-websocket-protocol': 'p1' });
123
+ expect(headers.authorization).toBeUndefined();
124
+ expect(headers['x-forwarded-for']).toBeUndefined();
125
+ expect(request.endsWith('\r\n\r\n')).toBe(true);
126
+ expect(() => buildHandshake({ innerPath: '/api/terminal/ws', search: '', requestHeaders: { 'sec-websocket-key': 'a\r\nx-injected: 1' }, cookie: 'c' })).toThrow(/line break/);
127
+ });
128
+
129
+ it('parses the answer inside and keeps the bytes after its headers', () => {
130
+ expect(parseHandshakeAnswer(Buffer.from('HTTP/1.1 101 Switching'))).toBeNull();
131
+ const parsed = parseHandshakeAnswer(Buffer.from('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: xyz\r\n\r\n\x81\x01a', 'latin1'));
132
+ expect(parsed.status).toBe(101);
133
+ expect(parsed.headers).toEqual({ upgrade: 'websocket', 'sec-websocket-accept': 'xyz' });
134
+ expect(parsed.rest).toEqual(Buffer.from('\x81\x01a', 'latin1'));
135
+ expect(() => parseHandshakeAnswer(Buffer.from('hello\r\n\r\n'))).toThrow(/not HTTP/);
136
+ });
137
+
138
+ it('writes a close frame as the protocol has it', () => {
139
+ const frame = closeFrame(1011, 'space_unreachable');
140
+ expect(frame[0]).toBe(0x88);
141
+ expect(frame[1]).toBe(2 + 'space_unreachable'.length);
142
+ expect(frame.readUInt16BE(2)).toBe(1011);
143
+ expect(frame.subarray(4).toString('utf8')).toBe('space_unreachable');
144
+ expect(closeFrame(1000, 'x'.repeat(200))[1]).toBe(125);
145
+ });
146
+ });
147
+
148
+ describe('the WebSocket forwarder', () => {
149
+ let inside;
150
+ let host;
151
+ let transport;
152
+ let dispatcher;
153
+ let forwarder;
154
+
155
+ const start = async ({ uiAuthController = { enabled: false }, isRequestOriginAllowed = async (req) => req.headers.origin === 'http://app.test', maxSocketsPerSpace } = {}) => {
156
+ inside = await startInside();
157
+ transport = transportTo(() => inside);
158
+ dispatcher = createSpaceDispatcher({ transport, logger });
159
+ forwarder = createSpaceWebSocketForwarder({ dispatcher, connect: (spaceId) => transport.connect(spaceId), uiAuthController, isRequestOriginAllowed, logger, maxSocketsPerSpace });
160
+ host = await startHost(forwarder);
161
+ };
162
+
163
+ afterEach(async () => {
164
+ forwarder?.close();
165
+ dispatcher?.close();
166
+ await host?.stop();
167
+ await inside?.stop();
168
+ logs.length = 0;
169
+ });
170
+
171
+ afterAll(() => { fs.rmSync(dataDir, { recursive: true, force: true }); });
172
+
173
+ it('forwards the terminal socket with the user\'s credentials removed, the space session added and the loopback origin', async () => {
174
+ await start();
175
+ const { socket, first } = await open(host.url(`/api/spaces/${ID}/terminal/ws?a=1&oc_url_token=secret-url`), { headers: { cookie: 'oc_ui_session_3000=user-cookie', authorization: 'Bearer user-bearer', 'x-custom': 'kept' } });
176
+ expect(first.hello).toBe('/api/terminal/ws?a=1');
177
+ expect(first.headers.cookie).toMatch(/^oc_ui_session_27600=[^;]+$/);
178
+ expect(first.headers.cookie).not.toContain('user-cookie');
179
+ expect(first.headers.authorization).toBeUndefined();
180
+ expect(first.headers.origin).toBe('http://127.0.0.1:27600');
181
+ expect(first.headers.host).toBe('127.0.0.1:27600');
182
+ expect(first.headers['x-custom']).toBe('kept');
183
+ expect(JSON.stringify(inside.state.upgrades)).not.toContain('secret-url');
184
+
185
+ socket.send('ping');
186
+ const echoed = await new Promise((resolve) => socket.once('message', (data) => resolve(data.toString('utf8'))));
187
+ expect(echoed).toBe('ping');
188
+ expect(inside.state.logins).toBe(1);
189
+ expect(transport.calls.connect).toBe(2);
190
+ socket.close();
191
+ expect(await until(() => inside.state.sockets.size === 0)).toBe(true);
192
+ expect(await until(() => forwarder.openSocketCount(ID) === 0)).toBe(true);
193
+ });
194
+
195
+ it('carries large frames whole in both directions', async () => {
196
+ await start();
197
+ const { socket } = await open(host.url(`/api/spaces/${ID}/event/ws?directory=${encodeURIComponent(`/spaces/${ID}/repo`)}`));
198
+ const payload = crypto.randomBytes(4 * 1024 * 1024);
199
+ socket.send(payload);
200
+ const echoed = await new Promise((resolve) => socket.once('message', (data) => resolve(data)));
201
+ expect(crypto.createHash('sha256').update(echoed).digest('hex')).toBe(crypto.createHash('sha256').update(payload).digest('hex'));
202
+ socket.close();
203
+ });
204
+
205
+ it('refuses before the upgrade with a stable code: unknown space, host-only route, unknown socket, a directory outside the space', async () => {
206
+ await start();
207
+ const cases = [
208
+ [`/api/spaces/${OTHER}/terminal/ws`, 404, 'space_not_found'],
209
+ ['/api/spaces/not-an-id/terminal/ws', 404, 'space_not_found'],
210
+ [`/api/spaces/${ID}/dictation/ws`, 403, 'host_only_route'],
211
+ [`/api/spaces/${ID}/preview/proxy/3000/ws`, 403, 'refused_across_boundary'],
212
+ [`/api/spaces/${ID}/guests/server-chrome/surface/ws`, 403, 'host_only_route'],
213
+ [`/api/spaces/${ID}/other/ws`, 404, 'space_socket_unknown'],
214
+ [`/api/spaces/${ID}/event/ws?directory=/home/me`, 400, 'directory_outside_space'],
215
+ [`/api/spaces/${ID}/event/ws?directory=/spaces/${OTHER}/repo`, 400, 'directory_outside_space'],
216
+ ];
217
+ for (const [suffix, status, code] of cases) {
218
+ const answer = await open(host.url(suffix));
219
+ expect(answer.status, suffix).toBe(status);
220
+ expect(answer.body, suffix).toMatchObject({ code });
221
+ }
222
+ expect(inside.state.upgrades).toHaveLength(0);
223
+ expect(transport.calls.connect).toBe(0);
224
+ });
225
+
226
+ it('answers the place\'s refusal and the space\'s own refusal with their codes', async () => {
227
+ await start();
228
+ const stoppedConnect = transport.connect;
229
+ transport.connect = async () => { throw new SpaceError('space_not_running', `Space ${ID} is stopped`); };
230
+ const stopped = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
231
+ expect(stopped.status).toBe(503);
232
+ expect(stopped.body).toMatchObject({ code: 'space_not_running' });
233
+ transport.connect = stoppedConnect;
234
+
235
+ const refused = await open(host.url(`/api/spaces/${ID}/dev-tunnel?port=4000`));
236
+ expect(refused.status).toBe(403);
237
+ expect(refused.body).toMatchObject({ code: 'space_socket_refused' });
238
+ const accepted = await open(host.url(`/api/spaces/${ID}/dev-tunnel?port=3000`));
239
+ expect(accepted.first.hello).toBe('/api/dev-tunnel?port=3000');
240
+ accepted.socket.close();
241
+ });
242
+
243
+ it('applies the host\'s own auth and origin rules before anything reaches the space', async () => {
244
+ const uiAuthController = {
245
+ enabled: true,
246
+ ensureSessionToken: async (req) => (req.headers.cookie === 'oc_ui_session_3000=user' ? 'user' : null),
247
+ resolveAuthContext: async (req) => (req.headers.authorization === 'Bearer client' ? { type: 'client', token: 'client:d' } : req.headers.cookie === 'oc_ui_session_3000=user' ? { type: 'session', token: 'user' } : null),
248
+ };
249
+ await start({ uiAuthController });
250
+ const anonymous = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
251
+ expect(anonymous.status).toBe(401);
252
+ const badOrigin = await open(host.url(`/api/spaces/${ID}/terminal/ws`), { headers: { cookie: 'oc_ui_session_3000=user', origin: 'http://evil.test' } });
253
+ expect(badOrigin.status).toBe(403);
254
+ expect(badOrigin.body).toMatchObject({ code: 'invalid_origin' });
255
+ expect(inside.state.upgrades).toHaveLength(0);
256
+ const user = await open(host.url(`/api/spaces/${ID}/terminal/ws`), { headers: { cookie: 'oc_ui_session_3000=user' } });
257
+ expect(user.first.hello).toBe('/api/terminal/ws');
258
+ user.socket.close();
259
+
260
+ // The dev tunnel also takes a client with its own token and no origin, as the host's does.
261
+ const client = await new Promise((resolve, reject) => {
262
+ const socket = new WebSocket(host.url(`/api/spaces/${ID}/dev-tunnel?port=3000`), { headers: { authorization: 'Bearer client' } });
263
+ socket.on('error', reject);
264
+ socket.on('unexpected-response', (_request, response) => resolve({ status: response.statusCode }));
265
+ socket.once('message', (data) => resolve({ socket, first: JSON.parse(data.toString('utf8')) }));
266
+ });
267
+ expect(client.first.hello).toBe('/api/dev-tunnel?port=3000');
268
+ expect(client.first.headers.authorization).toBeUndefined();
269
+ client.socket.close();
270
+ const sessionNoOrigin = await new Promise((resolve, reject) => {
271
+ const socket = new WebSocket(host.url(`/api/spaces/${ID}/dev-tunnel?port=3000`), { headers: { cookie: 'oc_ui_session_3000=user' } });
272
+ socket.on('error', reject);
273
+ socket.on('unexpected-response', (_request, response) => resolve({ status: response.statusCode }));
274
+ });
275
+ expect(sessionNoOrigin.status).toBe(403);
276
+ });
277
+
278
+ it('renews the session once when the server inside refuses it, and opens the socket on the fresh one', async () => {
279
+ await start();
280
+ (await open(host.url(`/api/spaces/${ID}/terminal/ws`))).socket.close();
281
+ inside.state.refuseNext = 1;
282
+ const { socket, first } = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
283
+ expect(first.hello).toBe('/api/terminal/ws');
284
+ expect(inside.state.logins).toBe(2);
285
+ socket.close();
286
+ });
287
+
288
+ it('closes the client with a code when the stream inside dies, and kills the stream when the client leaves', async () => {
289
+ await start();
290
+ const { socket } = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
291
+ const closed = waitForClose(socket);
292
+ // The server inside drops the socket, as it does when the space stops.
293
+ for (const insideSocket of inside.state.sockets) insideSocket.terminate();
294
+ expect(await closed).toEqual({ code: 1012, reason: 'space_closed' });
295
+ expect(await until(() => forwarder.openSocketCount(ID) === 0)).toBe(true);
296
+
297
+ const second = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
298
+ expect(forwarder.openSocketCount(ID)).toBe(1);
299
+ second.socket.terminate();
300
+ expect(await until(() => inside.state.sockets.size === 0)).toBe(true);
301
+ expect(await until(() => forwarder.openSocketCount(ID) === 0)).toBe(true);
302
+ });
303
+
304
+ it('counts a handshake under way against the cap, and gives the slot back when the client leaves during it', async () => {
305
+ await start({ maxSocketsPerSpace: 2 });
306
+ // The session inside exists already; what hangs below is the upgrade, not the login.
307
+ (await open(host.url(`/api/spaces/${ID}/terminal/ws`))).socket.close();
308
+ expect(await until(() => forwarder.openSocketCount(ID) === 0)).toBe(true);
309
+ // A stand-in that accepts the stream and never answers the upgrade.
310
+ // It reads, so it notices when the host lets go of a stream.
311
+ const silent = net.createServer((connection) => { connection.on('error', () => {}); connection.resume(); });
312
+ await new Promise((resolve) => silent.listen(0, '127.0.0.1', resolve));
313
+ const held = new Set();
314
+ silent.on('connection', (connection) => { held.add(connection); connection.on('close', () => held.delete(connection)); });
315
+ transport.connect = async () => net.connect({ host: '127.0.0.1', port: silent.address().port });
316
+ const pending = [0, 1].map(() => {
317
+ const socket = new WebSocket(host.url(`/api/spaces/${ID}/terminal/ws`), { headers: { origin: 'http://app.test' } });
318
+ socket.on('error', () => {});
319
+ return socket;
320
+ });
321
+ expect(await until(() => held.size === 2)).toBe(true);
322
+ expect(forwarder.openSocketCount(ID)).toBe(2);
323
+ const third = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
324
+ expect(third.status).toBe(503);
325
+ expect(third.body).toMatchObject({ code: 'space_socket_limit' });
326
+
327
+ // The client leaves in the middle of the handshake: the stream inside is killed at once.
328
+ pending[0].terminate();
329
+ expect(await until(() => held.size === 1)).toBe(true);
330
+ expect(await until(() => forwarder.openSocketCount(ID) === 1)).toBe(true);
331
+ pending[1].terminate();
332
+ expect(await until(() => held.size === 0)).toBe(true);
333
+ await new Promise((resolve) => silent.close(resolve));
334
+ });
335
+
336
+ it('ends every socket when it is closed', async () => {
337
+ await start();
338
+ const a = await open(host.url(`/api/spaces/${ID}/terminal/ws`));
339
+ const b = await open(host.url(`/api/spaces/${ID}/event/ws`));
340
+ const closes = Promise.all([waitForClose(a.socket), waitForClose(b.socket)]);
341
+ forwarder.close();
342
+ await closes;
343
+ expect(inside.state.sockets.size).toBe(0);
344
+ });
345
+ });
@@ -344,6 +344,10 @@ const parseUrlAuthScope = (value) => {
344
344
 
345
345
  const isGuestScopedPath = (pathname, guestId) => pathname.startsWith(`/api/guests/${guestId}/`);
346
346
 
347
+ // An isolated space's raw file and its sockets, under `/api/spaces/<id>/`, matched by shape.
348
+ const SPACE_RAW_FILE_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/fs\/raw$/;
349
+ const SPACE_WS_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/(?:terminal\/ws|dev-tunnel|event\/ws|global\/event\/ws)$/;
350
+
347
351
  const isUrlAuthReadableHttpPath = (pathname) => {
348
352
  return pathname === '/api/event'
349
353
  || pathname === '/api/global/event'
@@ -356,7 +360,8 @@ const isUrlAuthReadableHttpPath = (pathname) => {
356
360
  || pathname.startsWith('/api/preview/proxy/')
357
361
  || /^\/api\/projects\/[^/]+\/icon$/.test(pathname)
358
362
  || pathname === '/api/guests'
359
- || /^\/api\/guests\/[a-z][a-z0-9-]*\//.test(pathname);
363
+ || /^\/api\/guests\/[a-z][a-z0-9-]*\//.test(pathname)
364
+ || SPACE_RAW_FILE_PATH.test(pathname);
360
365
  };
361
366
 
362
367
  const isUrlAuthWebSocketPath = (pathname) => {
@@ -367,7 +372,8 @@ const isUrlAuthWebSocketPath = (pathname) => {
367
372
  || pathname === '/api/dictation/ws'
368
373
  || pathname === '/api/dev-tunnel'
369
374
  || /^\/api\/guests\/[a-z][a-z0-9-]*\/surface\/ws$/.test(pathname)
370
- || pathname.startsWith('/api/preview/proxy/');
375
+ || pathname.startsWith('/api/preview/proxy/')
376
+ || SPACE_WS_PATH.test(pathname);
371
377
  };
372
378
 
373
379
  const canUseUrlAuthTokenForRequest = (req, scope = null) => {
@@ -311,6 +311,35 @@ describe('ui auth client credential seam', () => {
311
311
  };
312
312
  expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1');
313
313
 
314
+ // The sockets and the raw file of an isolated space, by path shape, and nothing else under the prefix.
315
+ for (const socket of ['terminal/ws', 'dev-tunnel', 'event/ws', 'global/event/ws']) {
316
+ const spaceWsReq = {
317
+ method: 'GET',
318
+ path: `/api/spaces/a1b2c3d4e5f6/${socket}`,
319
+ url: `/api/spaces/a1b2c3d4e5f6/${socket}?oc_url_token=${encodeURIComponent(urlToken)}`,
320
+ headers: { upgrade: 'websocket' },
321
+ };
322
+ expect(await auth.ensureSessionToken(spaceWsReq, null)).toBe('client:device-1');
323
+ }
324
+ expect(await auth.ensureSessionToken({
325
+ method: 'GET',
326
+ path: '/api/spaces/a1b2c3d4e5f6/dictation/ws',
327
+ url: `/api/spaces/a1b2c3d4e5f6/dictation/ws?oc_url_token=${encodeURIComponent(urlToken)}`,
328
+ headers: { upgrade: 'websocket' },
329
+ }, null)).toBe(null);
330
+ expect(await auth.ensureSessionToken({
331
+ method: 'GET',
332
+ path: '/api/spaces/a1b2c3d4e5f6/fs/raw',
333
+ url: `/api/spaces/a1b2c3d4e5f6/fs/raw?path=x.png&oc_url_token=${encodeURIComponent(urlToken)}`,
334
+ headers: { accept: 'image/png' },
335
+ }, null)).toBe('client:device-1');
336
+ expect(await auth.ensureSessionToken({
337
+ method: 'GET',
338
+ path: '/api/spaces/a1b2c3d4e5f6/session',
339
+ url: `/api/spaces/a1b2c3d4e5f6/session?oc_url_token=${encodeURIComponent(urlToken)}`,
340
+ headers: { accept: 'application/json' },
341
+ }, null)).toBe(null);
342
+
314
343
  const devTunnelSubpathWsReq = {
315
344
  method: 'GET',
316
345
  path: '/api/dev-tunnel/private',
@@ -805,6 +805,119 @@ describe('OpenCode proxy SSE forwarding', () => {
805
805
  expect(cleared.metadata).toEqual({});
806
806
  });
807
807
 
808
+ it('hands the first page of the session list to the spaces merge, and later pages not', async () => {
809
+ const upstream = express();
810
+ upstream.get('/api/session', (req, res) => res.json({ data: [{ id: 'h1', location: { directory: '/repo' }, title: 'host', permissions: {} }], cursor: { next: req.query.cursor ? undefined : 'p2' } }));
811
+ upstreamServer = await listen(upstream);
812
+ const upstreamPort = upstreamServer.address().port;
813
+ const merges = [];
814
+ const app = express();
815
+ registerOpenCodeProxy(app, {
816
+ fs: {}, os: {}, path, OPEN_CODE_READY_GRACE_MS: 0,
817
+ getRuntime: () => ({ openCodePort: upstreamPort, isOpenCodeReady: true, openCodeNotReadySince: 0, isRestartingOpenCode: false }),
818
+ getOpenCodeAuthHeaders: () => ({}),
819
+ buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`,
820
+ ensureOpenCodeApiPrefix: () => {},
821
+ mergeSpaceSessionList: async (payload) => {
822
+ merges.push(payload);
823
+ return { ...payload, data: [...payload.data, { id: 's1', location: { directory: '/spaces/a1b2c3d4e5f6/repo' } }], spaces: [{ id: 'a1b2c3d4e5f6', state: 'complete', sessions: 1 }] };
824
+ },
825
+ });
826
+ proxyServer = await listen(app);
827
+ const base = `http://127.0.0.1:${proxyServer.address().port}`;
828
+
829
+ const first = await (await fetch(`${base}/api/session?limit=50`)).json();
830
+ expect(first.data.map((item) => item.id)).toEqual(['h1', 's1']);
831
+ expect(first.spaces).toEqual([{ id: 'a1b2c3d4e5f6', state: 'complete', sessions: 1 }]);
832
+ // The merge sees the host's list already sanitized.
833
+ expect(merges[0].data[0]).not.toHaveProperty('permissions');
834
+ const second = await (await fetch(`${base}/api/session?limit=50&cursor=p2`)).json();
835
+ expect(second.data.map((item) => item.id)).toEqual(['h1']);
836
+ expect(second.spaces).toBeUndefined();
837
+ // A list scoped to one directory, as the sidebar reads per project, is the host's alone.
838
+ const scoped = await (await fetch(`${base}/api/session?limit=50&directory=${encodeURIComponent('/repo')}`)).json();
839
+ expect(scoped.data.map((item) => item.id)).toEqual(['h1']);
840
+ expect(scoped.spaces).toBeUndefined();
841
+ const scopedByHeader = await (await fetch(`${base}/api/session?limit=50`, { headers: { 'x-opencode-directory': '/repo' } })).json();
842
+ expect(scopedByHeader.spaces).toBeUndefined();
843
+ expect(merges).toHaveLength(1);
844
+ });
845
+
846
+ it('writes the events of isolated spaces into the global event stream between the upstream\'s blocks', async () => {
847
+ const upstream = express();
848
+ let upstreamRes = null;
849
+ upstream.get('/api/event', (req, res) => {
850
+ res.setHeader('Content-Type', 'text/event-stream');
851
+ res.flushHeaders();
852
+ res.write('data: {"id":"h1","type":"host"}\n\n');
853
+ upstreamRes = res;
854
+ req.on('close', () => { upstreamRes = null; });
855
+ });
856
+ upstreamServer = await listen(upstream);
857
+ const upstreamPort = upstreamServer.address().port;
858
+ const subscribers = new Set();
859
+ const spaceEventHub = {
860
+ subscribeEvent: (subscriber, options) => {
861
+ const entry = { subscriber, options };
862
+ subscribers.add(entry);
863
+ return () => subscribers.delete(entry);
864
+ },
865
+ };
866
+ const app = express();
867
+ registerOpenCodeProxy(app, {
868
+ fs: {}, os: {}, path, OPEN_CODE_READY_GRACE_MS: 0,
869
+ getRuntime: () => ({ openCodePort: upstreamPort, isOpenCodeReady: true, openCodeNotReadySince: 0, isRestartingOpenCode: false }),
870
+ getOpenCodeAuthHeaders: () => ({}),
871
+ buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`,
872
+ ensureOpenCodeApiPrefix: () => {},
873
+ spaceEventHub,
874
+ });
875
+ proxyServer = await listen(app);
876
+ const base = `http://127.0.0.1:${proxyServer.address().port}`;
877
+
878
+ // A stream scoped by the directory header is the host's alone: no subscription for it.
879
+ const scopedController = new AbortController();
880
+ const scoped = await fetch(`${base}/api/event`, { headers: { Accept: 'text/event-stream', 'x-opencode-directory': '/repo' }, signal: scopedController.signal });
881
+ expect(scoped.status).toBe(200);
882
+ await new Promise((resolve) => setTimeout(resolve, 50));
883
+ expect(subscribers.size).toBe(0);
884
+ scopedController.abort();
885
+
886
+ const controller = new AbortController();
887
+ const response = await fetch(`${base}/api/global/event`, { headers: { Accept: 'text/event-stream' }, signal: controller.signal });
888
+ const reader = response.body.getReader();
889
+ const decoder = new TextDecoder();
890
+ let text = '';
891
+ const readUntil = async (needle) => {
892
+ while (!text.includes(needle)) {
893
+ const { value, done } = await reader.read();
894
+ if (done) break;
895
+ text += decoder.decode(value, { stream: true });
896
+ }
897
+ };
898
+ await readUntil('"h1"');
899
+ expect(subscribers.size).toBe(1);
900
+ const [{ subscriber, options }] = subscribers;
901
+ expect(options).toEqual({ spaces: true });
902
+ // A host event of the hub is not written twice; a space event is written as one block.
903
+ subscriber({ spaceId: null, payload: { id: 'x', type: 'host-from-hub' } });
904
+ subscriber({ spaceId: 'a1b2c3d4e5f6', payload: { id: 's1', type: 'session.execution.started', location: { directory: '/spaces/a1b2c3d4e5f6/repo' } } });
905
+ await readUntil('"s1"');
906
+ expect(text).toBe('data: {"id":"h1","type":"host"}\n\ndata: {"id":"s1","type":"session.execution.started","location":{"directory":"/spaces/a1b2c3d4e5f6/repo"}}\n\n');
907
+ // Half a block of the host's holds a space event back until the block is whole.
908
+ upstreamRes.write('data: {"id":"h2",');
909
+ await readUntil('"h2"');
910
+ subscriber({ spaceId: 'a1b2c3d4e5f6', payload: { id: 's2', type: 'session.execution.succeeded' } });
911
+ await new Promise((resolve) => setTimeout(resolve, 50));
912
+ expect(text).not.toContain('"s2"');
913
+ upstreamRes.write('"type":"host"}\n\n');
914
+ await readUntil('"s2"');
915
+ expect(text.endsWith('data: {"id":"h2","type":"host"}\n\ndata: {"id":"s2","type":"session.execution.succeeded"}\n\n')).toBe(true);
916
+ controller.abort();
917
+ await new Promise((resolve) => setTimeout(resolve, 50));
918
+ expect(subscribers.size).toBe(0);
919
+ });
920
+
808
921
  it('forwards unparsed SDK JSON bodies to generic API proxy requests', async () => {
809
922
  const upstream = express();
810
923
  upstream.post('/api/session/abc/revert', express.json(), (req, res) => {