@vantran-se/openchamber-web 2.0.3 → 2.0.5

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 (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -0,0 +1,273 @@
1
+ // The host of the feature: what `server/index.js` builds when the switch is on.
2
+
3
+ import fs from 'node:fs';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import express from 'express';
9
+ import { afterEach, describe, expect, it } from 'vitest';
10
+
11
+ import { createSpacesHost, readOrCreateOwner } from './host.js';
12
+ import { hashProjectDirectory } from './labels.js';
13
+ import { createMemoryPlace } from './places/memory-place.js';
14
+
15
+ const folders = [];
16
+ const servers = [];
17
+ const hosts = [];
18
+ const temporary = () => {
19
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-spaces-host-test-'));
20
+ folders.push(folder);
21
+ return folder;
22
+ };
23
+
24
+ afterEach(async () => {
25
+ for (const host of hosts.splice(0)) host.close();
26
+ for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve()));
27
+ for (const folder of folders.splice(0)) fs.rmSync(folder, { recursive: true, force: true });
28
+ });
29
+
30
+ describe('readOrCreateOwner', () => {
31
+ it('makes a random owner once, readable by the user only, and keeps it', () => {
32
+ const dataDir = temporary();
33
+ const owner = readOrCreateOwner(dataDir);
34
+ expect(owner).toMatch(/^[0-9a-f]{24}$/);
35
+ expect(readOrCreateOwner(dataDir)).toBe(owner);
36
+ const file = path.join(dataDir, 'spaces', 'owner');
37
+ expect(fs.readFileSync(file, 'utf8')).toBe(`${owner}\n`);
38
+ if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600);
39
+ });
40
+
41
+ it('replaces a file that does not hold an owner', () => {
42
+ const dataDir = temporary();
43
+ fs.mkdirSync(path.join(dataDir, 'spaces'));
44
+ fs.writeFileSync(path.join(dataDir, 'spaces', 'owner'), 'not an owner\n');
45
+ expect(readOrCreateOwner(dataDir)).toMatch(/^[0-9a-f]{24}$/);
46
+ });
47
+ });
48
+
49
+ describe('createSpacesHost', () => {
50
+ it('starts no process of its own when it is made, and answers through the place it was given', async () => {
51
+ let started = 0;
52
+ const place = createMemoryPlace();
53
+ const host = createSpacesHost({
54
+ dataDir: temporary(),
55
+ place,
56
+ runCommand: async () => { started += 1; throw new Error('no process here'); },
57
+ openCommandStream: () => { started += 1; throw new Error('no process here'); },
58
+ logger: { warn: () => {} },
59
+ });
60
+ hosts.push(host);
61
+ const { id } = await host.manager.createSpace({ placeId: 'memory', projectDirectory: '/home/me/project', name: 'Host test' });
62
+
63
+ const app = express();
64
+ app.use(host.middleware);
65
+ app.get('/api/host', (req, res) => res.json({ directory: req.query.directory ?? null }));
66
+ const server = http.createServer(app);
67
+ servers.push(server);
68
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
69
+ const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
70
+
71
+ // The memory place has no token file to read, so the login step stops there: what this
72
+ // shows is the wiring, from the prefix through the manager's list to the space's channel.
73
+ const forwarded = await fetch(url(`/api/spaces/${id}/health`));
74
+ expect(forwarded.status).toBe(502);
75
+ expect(await forwarded.json()).toMatchObject({ code: 'space_setup_failed' });
76
+ const unknown = await fetch(url('/api/spaces/0f0f0f0f0f0f/health'));
77
+ expect(unknown.status).toBe(404);
78
+ const guarded = await fetch(url(`/api/host?directory=/spaces/${id}/repo`));
79
+ expect(guarded.status).toBe(400);
80
+ expect((await fetch(url('/api/host?directory=/home/me'))).status).toBe(200);
81
+
82
+ expect(host.skipsBodyParsing({ path: `/api/spaces/${id}/fs/raw` })).toBe(true);
83
+ expect(host.skipsBodyParsing({ path: '/api/fs/raw' })).toBe(false);
84
+ expect(host.refuseDirectory(`/spaces/${id}/repo`)).toMatch(/isolated space/);
85
+ expect(host.refuseDirectory('/home/me/spaces')).toBeNull();
86
+ expect(started).toBe(0);
87
+ await place.remove(id);
88
+ });
89
+
90
+ it('carries the journey and its places, and takes no upgrade before the forwarder is made', async () => {
91
+ const place = createMemoryPlace();
92
+ const host = createSpacesHost({ dataDir: temporary(), place, listProjectDirectories: async () => ['/home/me/project'], logger: { warn: () => {} } });
93
+ hosts.push(host);
94
+ expect(host.places().map((entry) => entry.id)).toEqual(['memory']);
95
+ expect(await host.journey.listSpaces()).toEqual([]);
96
+ // The slot in index.js calls this whether or not the forwarder exists yet.
97
+ expect(() => host.upgradeHandler({ url: '/api/spaces/a1b2c3d4e5f6/api/event/ws', headers: {} }, { destroy: () => {} }, Buffer.alloc(0))).not.toThrow();
98
+ // The middleware is the dispatcher's: a request outside the prefix passes through.
99
+ let passed = false;
100
+ host.middleware({ path: '/api/host', url: '/api/host', headers: {}, query: {}, method: 'GET' }, {}, () => { passed = true; });
101
+ expect(passed).toBe(true);
102
+ });
103
+ });
104
+
105
+ // A stand-in place whose spaces are Express apps with the host's real UI auth: the merged
106
+ // session list, the event connections and the socket forwarding, wired the way `index.js` wires them.
107
+ import net from 'node:net';
108
+ import { WebSocket, WebSocketServer } from 'ws';
109
+ import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
110
+ import { SpaceError } from './errors.js';
111
+ import { IMAGE_CAT } from './layout.js';
112
+
113
+ const TOKEN = 'tok_' + 'a'.repeat(40);
114
+ const ID = 'a1b2c3d4e5f6';
115
+ const OTHER = '0f0f0f0f0f0f';
116
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
117
+ const until = async (check, timeoutMs = 3_000) => {
118
+ const deadline = Date.now() + timeoutMs;
119
+ while (!check() && Date.now() < deadline) await sleep(10);
120
+ return check();
121
+ };
122
+
123
+ /** One space's server: the sessions it lists, in pages of two, an event stream, and an echo socket. */
124
+ const startSpaceServer = async ({ sessions, status = {} }) => {
125
+ const { createUiAuth } = await import('../ui-auth/ui-auth.js');
126
+ const auth = createUiAuth({ password: TOKEN, readSettingsFromDiskMigrated: async () => ({}) });
127
+ const state = { eventStreams: [], sessions, listRequests: 0 };
128
+ const app = express();
129
+ app.post('/auth/session', express.json(), auth.handleSessionCreate);
130
+ app.use('/api', auth.requireAuth);
131
+ app.get('/api/session', (req, res) => {
132
+ state.listRequests += 1;
133
+ const start = Number.parseInt(req.query.cursor ?? '0', 10);
134
+ const page = state.sessions.slice(start, start + 2);
135
+ res.json({ data: page, cursor: { next: start + 2 < state.sessions.length ? String(start + 2) : undefined } });
136
+ });
137
+ app.get('/api/sessions/status', (_req, res) => res.json({ sessions: status }));
138
+ app.get('/api/event', (req, res) => {
139
+ res.setHeader('content-type', 'text/event-stream');
140
+ res.flushHeaders();
141
+ res.write(':ready\n\n');
142
+ state.eventStreams.push(res);
143
+ req.on('close', () => { state.eventStreams.splice(state.eventStreams.indexOf(res), 1); });
144
+ });
145
+ const server = http.createServer(app);
146
+ const wsServer = new WebSocketServer({ noServer: true });
147
+ wsServer.on('connection', (socket) => { socket.on('message', (data) => socket.send(`echo:${data}`)); });
148
+ server.on('upgrade', (req, socket, head) => {
149
+ void auth.ensureSessionToken(req, null).then((token) => {
150
+ if (!token || new URL(req.headers.origin ?? 'http://x').host !== req.headers.host) { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); socket.destroy(); return; }
151
+ wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
152
+ });
153
+ });
154
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
155
+ servers.push(server);
156
+ return { port: server.address().port, state, wsServer, server };
157
+ };
158
+
159
+ const fakePlace = (spaces) => ({
160
+ id: 'fake',
161
+ check: async () => ({ available: true }),
162
+ create: async () => {},
163
+ list: async () => Array.from(spaces.entries(), ([id, space]) => ({ id, name: id, project: space.project ?? 'p', created: '', state: space.running ? 'running' : 'exited', orphans: [], damaged: false, missing: [] })),
164
+ exec: async (_spaceId, argv) => (argv[0] === IMAGE_CAT ? { code: 0, stdout: `${TOKEN}\n`, stderr: '' } : { code: 127, stdout: '', stderr: 'not found' }),
165
+ execArgv: async () => [],
166
+ connect: async (spaceId) => {
167
+ const space = spaces.get(spaceId);
168
+ if (!space) throw new SpaceError('space_not_found', 'gone');
169
+ if (!space.running) throw new SpaceError('space_not_running', `Space ${spaceId} is stopped`);
170
+ return net.connect({ host: '127.0.0.1', port: space.port });
171
+ },
172
+ stop: async (spaceId) => { spaces.get(spaceId).running = false; },
173
+ start: async (spaceId) => { spaces.get(spaceId).running = true; },
174
+ remove: async (spaceId) => { spaces.delete(spaceId); return { removed: [], failed: [] }; },
175
+ verify: async () => [],
176
+ });
177
+
178
+ describe('createSpacesHost: sessions and events', () => {
179
+ const logs = [];
180
+ const logger = { warn: (line) => logs.push(line) };
181
+ afterEach(() => { logs.length = 0; });
182
+
183
+ it('merges every reachable space\'s sessions after the host\'s, drops what a space may not claim, and keeps a stopped space\'s last list as stale', async () => {
184
+ const first = await startSpaceServer({ sessions: [
185
+ { id: 'a1', location: { directory: `/spaces/${ID}/repo` }, title: 'one', permissions: { x: 1 } },
186
+ { id: 'a2', location: { directory: `/spaces/${ID}/repo/sub` }, title: 'two' },
187
+ { id: 'a3', location: { directory: `/spaces/${ID}` }, title: 'three' },
188
+ { id: 'host-1', location: { directory: `/spaces/${ID}/repo` }, title: 'a host session, claimed' },
189
+ { id: 'a4', location: { directory: '/home/me/project' }, title: 'outside' },
190
+ ] });
191
+ // Two spaces that list the same id are read at the same time, so which one keeps it is not
192
+ // fixed; that rule is proved in `space-sessions.test.js`, and here the two lists are apart.
193
+ const second = await startSpaceServer({ sessions: [{ id: 'b1', location: { directory: `/spaces/${OTHER}/repo` } }] });
194
+ // The first space was made for a registered project, the second for one this host no longer has.
195
+ const spaces = new Map([[ID, { port: first.port, running: true, project: hashProjectDirectory('/home/me/project') }], [OTHER, { port: second.port, running: true }]]);
196
+ const host = createSpacesHost({ dataDir: temporary(), place: fakePlace(spaces), logger, setTimer: () => null, clearTimer: () => {}, listProjectDirectories: async () => ['/home/me/project', '/home/me/other'] });
197
+ hosts.push(host);
198
+ const hostList = { data: [{ id: 'host-1', location: { directory: '/home/me' } }], cursor: {} };
199
+ const first_mark = { id: ID, name: ID, state: 'complete', sessions: 3, projectDirectory: '/home/me/project', directory: `/spaces/${ID}/project` };
200
+ const other_mark = (state, sessions) => ({ id: OTHER, name: OTHER, state, sessions, projectDirectory: null, directory: null });
201
+
202
+ const merged = await host.mergeSessionList(hostList);
203
+ expect(merged.data.map((item) => item.id)).toEqual(['host-1', 'a1', 'a2', 'a3', 'b1']);
204
+ // Within two seconds a second merge serves the accepted lists without asking the spaces again.
205
+ const pagesRead = first.state.listRequests;
206
+ expect(pagesRead).toBe(3);
207
+ expect((await host.mergeSessionList(hostList)).data.map((item) => item.id)).toEqual(['host-1', 'a1', 'a2', 'a3', 'b1']);
208
+ expect(first.state.listRequests).toBe(pagesRead);
209
+ await sleep(2_100);
210
+ expect(merged.data[1]).not.toHaveProperty('permissions');
211
+ expect(merged.spaces).toEqual([first_mark, other_mark('complete', 1)]);
212
+ expect(logs.join('\n')).toContain('space_session_host_id');
213
+ expect(logs.join('\n')).toContain('space_session_outside_root');
214
+ expect(logs.join('\n')).not.toContain('a host session, claimed');
215
+
216
+ // A stopped space takes the pooled streams with it: the stand-in drops its connections too.
217
+ await host.manager.stopSpace({ placeId: 'fake', spaceId: OTHER });
218
+ second.server.closeAllConnections();
219
+ second.state.sessions.length = 0;
220
+ const stale = await host.mergeSessionList(hostList);
221
+ expect(stale.data.map((item) => item.id)).toEqual(['host-1', 'a1', 'a2', 'a3', 'b1']);
222
+ expect(stale.spaces).toEqual([first_mark, other_mark('stale', 1)]);
223
+ expect(logs.join('\n')).toContain(`the session list of space ${OTHER} did not come`);
224
+
225
+ // Back, and empty: an empty answer is an answer.
226
+ await host.manager.startSpace({ placeId: 'fake', spaceId: OTHER });
227
+ const empty = await host.mergeSessionList(hostList);
228
+ expect(empty.spaces[1]).toEqual(other_mark('complete', 0));
229
+
230
+ // A space that is gone is not listed; a host without spaces answers with the very same object.
231
+ spaces.clear();
232
+ await sleep(2_100);
233
+ expect(await host.mergeSessionList(hostList)).toBe(hostList);
234
+ }, 10_000);
235
+
236
+ it('follows the spaces into the hub: their events arrive marked, the host\'s ids are learned from the hub, and sockets are forwarded', async () => {
237
+ const space = await startSpaceServer({ sessions: [], status: { busy1: { status: 'busy' } } });
238
+ const spaces = new Map([[ID, { port: space.port, running: true }]]);
239
+ const host = createSpacesHost({ dataDir: temporary(), place: fakePlace(spaces), logger, setTimer: () => null, clearTimer: () => {} });
240
+ hosts.push(host);
241
+ const hub = createGlobalMessageStreamHub({ buildOpenCodeUrl: (p) => `http://127.0.0.1:1${p}`, getOpenCodeAuthHeaders: () => ({}), deltaCoalesceWindowMs: 0, fetchImpl: async () => { throw new Error('no host upstream here'); } });
242
+ const received = [];
243
+ hub.subscribeEvent((event) => received.push(event), { spaces: true });
244
+ await host.startEvents(hub);
245
+ expect(await until(() => space.state.eventStreams.length === 1)).toBe(true);
246
+ expect(await until(() => received.some((event) => event.payload.type === 'session.status' && event.payload.data.sessionID === 'busy1'))).toBe(true);
247
+
248
+ hub.injectEvent({ payload: { type: 'session.created', data: { sessionID: 'host-new' } }, directory: '/home/me', spaceId: null });
249
+ space.state.eventStreams[0].write(`data: ${JSON.stringify({ id: 'e1', type: 'session.execution.started', data: { sessionID: 'host-new' } })}\n\n`);
250
+ space.state.eventStreams[0].write(`data: ${JSON.stringify({ id: 'e2', type: 'session.execution.started', data: { sessionID: 'mine' }, location: { directory: `/spaces/${ID}/repo` } })}\n\n`);
251
+ expect(await until(() => received.some((event) => event.payload.id === 'e2'))).toBe(true);
252
+ expect(received.find((event) => event.payload.id === 'e2')).toMatchObject({ spaceId: ID, directory: `/spaces/${ID}/repo` });
253
+ expect(received.some((event) => event.payload.id === 'e1')).toBe(false);
254
+ expect(logs.join('\n')).toContain('space_event_host_session');
255
+
256
+ const server = http.createServer((_req, res) => { res.statusCode = 404; res.end(); });
257
+ servers.push(server);
258
+ host.prepareUpgrades({ uiAuthController: { enabled: false }, isRequestOriginAllowed: async () => true });
259
+ server.on('upgrade', host.upgradeHandler);
260
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
261
+ const socket = new WebSocket(`ws://127.0.0.1:${server.address().port}/api/spaces/${ID}/terminal/ws`, { headers: { origin: 'http://app.test' } });
262
+ const echoed = await new Promise((resolve, reject) => {
263
+ socket.on('open', () => socket.send('hi'));
264
+ socket.on('message', (data) => resolve(data.toString('utf8')));
265
+ socket.on('error', reject);
266
+ });
267
+ expect(echoed).toBe('echo:hi');
268
+ socket.close();
269
+ host.close();
270
+ expect(await until(() => space.state.eventStreams.length === 0)).toBe(true);
271
+ space.wsServer.close();
272
+ });
273
+ });