@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,296 @@
1
+ // The dispatcher in front of a real space on a real Docker daemon. Runs only with
2
+ // OPENCHAMBER_TEST_DOCKER=1. Two parts: the dispatcher over the Docker place, mounted in an
3
+ // Express app the way `server/index.js` mounts it; and the real server started headless, once
4
+ // with the switch off and once with it on, in front of a stand-in Docker daemon that records
5
+ // whether anything ever spoke to it.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import http from 'node:http';
10
+ import net from 'node:net';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import express from 'express';
16
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
17
+
18
+ import { createSpacesHost } from '../host.js';
19
+ import { createSpaceId, hashProjectDirectory } from '../labels.js';
20
+ import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
21
+
22
+ const CREATE_TIMEOUT_MS = 25 * 60_000;
23
+ const SERVER_ENTRY = fileURLToPath(new URL('../../../index.js', import.meta.url));
24
+
25
+ const temporary = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
26
+
27
+ const listen = (app) => new Promise((resolve) => {
28
+ const server = http.createServer(app);
29
+ server.listen(0, '127.0.0.1', () => resolve(server));
30
+ });
31
+
32
+ describe.skipIf(!LIVE_DOCKER_ENABLED)('dispatcher over a real space: docker (live)', () => {
33
+ const spec = {
34
+ id: createSpaceId(),
35
+ name: 'Dispatcher live',
36
+ project: hashProjectDirectory('/dispatcher/live/project'),
37
+ created: new Date().toISOString(),
38
+ memoryBytes: 2 * 1024 * 1024 * 1024,
39
+ };
40
+ const repo = `/spaces/${spec.id}/repo`;
41
+ let place;
42
+ let dispose = async () => {};
43
+ let dataDir;
44
+ let host;
45
+ let server;
46
+ let hostRouteRuns = 0;
47
+ const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
48
+ const shell = async (script) => {
49
+ const result = await place.exec(spec.id, ['sh', '-c', script]);
50
+ expect(result.code, `${script}: ${result.stderr}`).toBe(0);
51
+ return result.stdout;
52
+ };
53
+
54
+ beforeAll(async () => {
55
+ ({ place, dispose } = createLiveDockerPlace());
56
+ await place.create(spec);
57
+ await shell(`mkdir -p ${repo} && cd ${repo} && git init -q . && git -c user.email=space@example.invalid -c user.name=Space commit -q --allow-empty -m init`);
58
+ dataDir = temporary('openchamber-dispatcher-live-');
59
+ host = createSpacesHost({ dataDir, place, logger: { warn: () => {} } });
60
+ const app = express();
61
+ // The host's auth gate stands in front of the dispatcher in the real server; here the request
62
+ // is taken as authenticated, and one host route records whether it ever ran.
63
+ app.use(host.middleware);
64
+ app.all('/api/git/status', (req, res) => {
65
+ hostRouteRuns += 1;
66
+ res.json({ ranOnHost: true, directory: req.query.directory ?? null });
67
+ });
68
+ app.use((_req, res) => res.status(404).json({ error: 'not found on host' }));
69
+ server = await listen(app);
70
+ }, CREATE_TIMEOUT_MS);
71
+
72
+ afterAll(async () => {
73
+ host?.close();
74
+ await new Promise((resolve) => { if (server) server.close(() => resolve()); else resolve(); });
75
+ await dispose();
76
+ if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
77
+ });
78
+
79
+ it('forwards a prefixed request to the server inside and returns its answer', async () => {
80
+ const response = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
81
+ expect(response.status).toBe(200);
82
+ expect(response.headers.get('x-content-type-options')).toBe('nosniff');
83
+ expect(response.headers.get('set-cookie')).toBeNull();
84
+ const list = await response.json();
85
+ expect(Array.isArray(list.data ?? list)).toBe(true);
86
+ });
87
+
88
+ it('creates a session inside through a streamed POST body, and lists it back', async () => {
89
+ const created = await fetch(url(`/api/spaces/${spec.id}/session`), {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify({ location: { directory: repo } }),
93
+ });
94
+ expect(created.status, await created.clone().text()).toBe(200);
95
+ const session = (await created.json()).data;
96
+ expect(session.location.directory).toBe(repo);
97
+
98
+ const listed = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
99
+ const ids = ((await listed.json()).data ?? []).map((entry) => entry.id);
100
+ expect(ids).toContain(session.id);
101
+ });
102
+
103
+ it('streams the event stream of the server inside', async () => {
104
+ const controller = new AbortController();
105
+ const response = await fetch(url(`/api/spaces/${spec.id}/event`), { signal: controller.signal, headers: { accept: 'text/event-stream' } });
106
+ expect(response.status).toBe(200);
107
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
108
+ const reader = response.body.getReader();
109
+ const { value, done } = await reader.read();
110
+ expect(done).toBe(false);
111
+ expect(new TextDecoder().decode(value).length).toBeGreaterThan(0);
112
+ controller.abort();
113
+ }, 60_000);
114
+
115
+ it('refuses a prefixed request that names a directory outside the space, and a host-only route', async () => {
116
+ const outside = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent('/home/me/project')}`));
117
+ expect(outside.status).toBe(400);
118
+ expect(await outside.json()).toMatchObject({ code: 'directory_outside_space' });
119
+ const hostOnly = await fetch(url(`/api/spaces/${spec.id}/config/settings`));
120
+ expect(hostOnly.status).toBe(403);
121
+ expect(await hostOnly.json()).toMatchObject({ code: 'host_only_route' });
122
+ });
123
+
124
+ // The escape check of TESTING.md: a request without the space prefix, but with a space
125
+ // directory, does not run on the host.
126
+ it('refuses an unprefixed request with the space\'s directory before any host route runs', async () => {
127
+ const response = await fetch(url(`/api/git/status?directory=${encodeURIComponent(repo)}`));
128
+ expect(response.status).toBe(400);
129
+ expect(await response.json()).toMatchObject({ code: 'space_directory_needs_prefix' });
130
+ expect(hostRouteRuns).toBe(0);
131
+ const ordinary = await fetch(url('/api/git/status?directory=/tmp'));
132
+ expect(ordinary.status).toBe(200);
133
+ expect(hostRouteRuns).toBe(1);
134
+ });
135
+
136
+ // Processes inside are read from /proc, because the image has no pgrep. The match is anchored
137
+ // to the bridge's own command line, so the shell that runs this count is not counted.
138
+ const bridgesInside = async () => Number((await shell('n=0; for p in /proc/[0-9]*; do if tr "\\0" " " < "$p/cmdline" 2>/dev/null | grep -q "^/usr/local/bin/node -e "; then n=$((n+1)); fi; done; echo $n')).trim());
139
+
140
+ it('ends the bridge inside when the host closes its connections', async () => {
141
+ expect((await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`))).status).toBe(200);
142
+ expect(await bridgesInside()).toBeGreaterThan(0);
143
+ host.close();
144
+ let left = await bridgesInside();
145
+ for (let attempt = 0; attempt < 40 && left > 0; attempt += 1) {
146
+ await new Promise((resolve) => { setTimeout(resolve, 250); });
147
+ left = await bridgesInside();
148
+ }
149
+ expect(left).toBe(0);
150
+ // The next request opens a new one.
151
+ expect((await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`))).status).toBe(200);
152
+ }, 60_000);
153
+
154
+ it('answers 503 with a stable code for a stopped space, and serves it again after a start', async () => {
155
+ await place.stop(spec.id);
156
+ const stopped = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
157
+ expect(stopped.status).toBe(503);
158
+ expect(await stopped.json()).toMatchObject({ code: 'space_not_running' });
159
+
160
+ await place.start(spec.id);
161
+ const again = await fetch(url(`/api/spaces/${spec.id}/session?directory=${encodeURIComponent(repo)}`));
162
+ expect(again.status).toBe(200);
163
+ }, 5 * 60_000);
164
+ });
165
+
166
+ /**
167
+ * The real server, started headless, with a stand-in for the Docker daemon on `DOCKER_HOST`
168
+ * that records every connection. The docker CLI on this machine is the real one: whether the
169
+ * feature ever called it is exactly what the daemon's connection count says.
170
+ */
171
+ const startFakeDaemon = async (folder) => {
172
+ const socketPath = path.join(folder, 'docker.sock');
173
+ let connections = 0;
174
+ const open = new Set();
175
+ const daemon = net.createServer((socket) => {
176
+ connections += 1;
177
+ open.add(socket);
178
+ socket.on('close', () => open.delete(socket));
179
+ socket.on('error', () => {});
180
+ socket.end('HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\n\r\n');
181
+ });
182
+ await new Promise((resolve) => daemon.listen(socketPath, resolve));
183
+ return {
184
+ host: `unix://${socketPath}`,
185
+ connections: () => connections,
186
+ // A docker CLI may hold a connection open; `close` alone would wait for it for ever.
187
+ stop: () => new Promise((resolve) => {
188
+ for (const socket of open) socket.destroy();
189
+ daemon.close(() => resolve());
190
+ }),
191
+ };
192
+ };
193
+
194
+ const freePort = () => new Promise((resolve) => {
195
+ const probe = net.createServer();
196
+ probe.listen(0, '127.0.0.1', () => {
197
+ const { port } = probe.address();
198
+ probe.close(() => resolve(port));
199
+ });
200
+ });
201
+
202
+ const startServer = async ({ dataDir, dockerHost, port }) => {
203
+ const child = spawn(process.execPath, [SERVER_ENTRY, '--port', String(port), '--api-only'], {
204
+ env: { ...process.env, OPENCHAMBER_DATA_DIR: dataDir, DOCKER_HOST: dockerHost, OPENCHAMBER_RELAY_HOST: 'off' },
205
+ stdio: ['ignore', 'pipe', 'pipe'],
206
+ });
207
+ let output = '';
208
+ child.stdout.on('data', (chunk) => { output += chunk; });
209
+ child.stderr.on('data', (chunk) => { output += chunk; });
210
+ const base = `http://127.0.0.1:${port}`;
211
+ const deadline = Date.now() + 60_000;
212
+ let healthy = false;
213
+ while (!healthy && Date.now() < deadline) {
214
+ if (child.exitCode !== null) throw new Error(`the server left early:\n${output.slice(-2000)}`);
215
+ try {
216
+ healthy = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(5_000) })).status === 200;
217
+ } catch {
218
+ // Not up yet.
219
+ }
220
+ if (!healthy) await new Promise((resolve) => { setTimeout(resolve, 250); });
221
+ }
222
+ if (!healthy) throw new Error(`the server did not become healthy:\n${output.slice(-2000)}`);
223
+ return {
224
+ base,
225
+ /** One request with a bound, so a hang names itself and the server's output instead of a timeout. */
226
+ get: async (suffix) => {
227
+ try {
228
+ return await fetch(`${base}${suffix}`, { signal: AbortSignal.timeout(30_000) });
229
+ } catch (error) {
230
+ throw new Error(`${suffix}: ${error.message}\n${output.slice(-2000)}`);
231
+ }
232
+ },
233
+ output: () => output,
234
+ stop: () => new Promise((resolve) => {
235
+ child.once('exit', resolve);
236
+ child.kill('SIGTERM');
237
+ setTimeout(() => child.kill('SIGKILL'), 10_000).unref();
238
+ }),
239
+ };
240
+ };
241
+
242
+ describe.skipIf(!LIVE_DOCKER_ENABLED)('the switch, on the real server (live)', () => {
243
+ const folders = [];
244
+ afterAll(() => {
245
+ for (const folder of folders) fs.rmSync(folder, { recursive: true, force: true });
246
+ });
247
+
248
+ const startWith = async (enabled) => {
249
+ const folder = temporary('openchamber-switch-live-');
250
+ folders.push(folder);
251
+ const dataDir = path.join(folder, 'data');
252
+ fs.mkdirSync(dataDir, { recursive: true });
253
+ // The server copies `projects`, `themes` and `speech-models` from the default data directory
254
+ // into a fresh one at start; the last of those can be gigabytes. Present and empty, they stay.
255
+ for (const entry of ['projects', 'themes', 'speech-models']) fs.mkdirSync(path.join(dataDir, entry));
256
+ fs.writeFileSync(path.join(dataDir, 'settings.json'), JSON.stringify({ isolatedSpacesEnabled: enabled }));
257
+ const daemon = await startFakeDaemon(folder);
258
+ const server = await startServer({ dataDir, dockerHost: daemon.host, port: await freePort() });
259
+ return { dataDir, daemon, server, stop: async () => { await server.stop(); await daemon.stop(); } };
260
+ };
261
+
262
+ it('registers no route of the feature and never speaks to Docker while the switch is off', async () => {
263
+ const run = await startWith(false);
264
+ try {
265
+ const prefixed = await run.server.get('/api/spaces/aaaaaaaaaaaa/session');
266
+ // Whatever the host answers, it is not the feature: no space code, and the request went on
267
+ // to the host's own routes.
268
+ const body = await prefixed.text();
269
+ expect(body).not.toMatch(/space_not_found|space_unreachable|host_only_route/);
270
+ const unguarded = await run.server.get('/api/git/status?directory=/spaces/aaaaaaaaaaaa/repo');
271
+ expect(unguarded.status).not.toBe(400);
272
+ expect(run.daemon.connections()).toBe(0);
273
+ expect(fs.existsSync(path.join(run.dataDir, 'spaces'))).toBe(false);
274
+ } finally {
275
+ await run.stop();
276
+ }
277
+ }, 120_000);
278
+
279
+ it('registers the routes and asks Docker for its spaces while the switch is on', async () => {
280
+ const run = await startWith(true);
281
+ try {
282
+ const prefixed = await run.server.get('/api/spaces/aaaaaaaaaaaa/session');
283
+ expect(prefixed.status).toBe(502);
284
+ expect(await prefixed.json()).toMatchObject({ code: 'space_unreachable' });
285
+ expect(run.daemon.connections()).toBeGreaterThan(0);
286
+ const guarded = await run.server.get('/api/git/status?directory=/spaces/aaaaaaaaaaaa/repo');
287
+ expect(guarded.status).toBe(400);
288
+ expect(await guarded.json()).toMatchObject({ code: 'space_directory_needs_prefix' });
289
+ const hostOnly = await run.server.get('/api/spaces/aaaaaaaaaaaa/config/settings');
290
+ expect(hostOnly.status).toBe(403);
291
+ expect(fs.existsSync(path.join(run.dataDir, 'spaces', 'owner'))).toBe(true);
292
+ } finally {
293
+ await run.stop();
294
+ }
295
+ }, 120_000);
296
+ });
@@ -118,13 +118,21 @@ export function createLiveDockerPlace({ toolsSource = createRegistryToolsSource(
118
118
  const crypto = require('node:crypto');
119
119
  const hash = (value) => (value === undefined ? 'none' : 'sha256:' + crypto.createHash('sha256').update(String(value)).digest('hex'));
120
120
  require('node:http').createServer((request, response) => {
121
- response.writeHead(200, { 'content-type': 'application/json' });
122
- response.end(JSON.stringify({
121
+ const seen = {
122
+ method: request.method,
123
123
  path: request.url,
124
124
  authorization: hash(request.headers.authorization),
125
125
  apiKey: hash(request.headers['x-api-key']),
126
126
  headerNames: Object.keys(request.headers).sort(),
127
- }));
127
+ };
128
+ // One line per request in the container's log, so the host can read what a client it does
129
+ // not control, OpenCode inside a space, sent here. Hashes only, as in the answer.
130
+ console.log('seen ' + JSON.stringify(seen));
131
+ request.resume();
132
+ request.on('end', () => {
133
+ response.writeHead(200, { 'content-type': 'application/json' });
134
+ response.end(JSON.stringify(seen));
135
+ });
128
136
  }).listen(9000, '0.0.0.0', () => console.log('upstream listening'));
129
137
  `;
130
138
  await docker([
@@ -136,9 +144,24 @@ require('node:http').createServer((request, response) => {
136
144
  await new Promise((resolve) => { setTimeout(resolve, 250); });
137
145
  if ((await docker(['logs', name])).stdout.includes('upstream listening')) break;
138
146
  }
139
- return { url: 'http://upstream:9000/v1', stop: () => docker(['rm', '--force', name]) };
147
+ return {
148
+ url: 'http://upstream:9000/v1',
149
+ /** Every request this upstream has seen so far, oldest first, from its log. */
150
+ seen: async () => (await docker(['logs', name])).stdout.split('\n').filter((line) => line.startsWith('seen ')).map((line) => JSON.parse(line.slice(5))),
151
+ stop: () => docker(['rm', '--force', name]),
152
+ };
140
153
  },
141
154
 
155
+ /**
156
+ * Runs a Node script in a helper container on the space's outer network, where the stand-in
157
+ * upstream sits and where, on a Linux host, the host's own processes can reach. What such a
158
+ * neighbour can reach on the gatekeeper is what the gatekeeper's bind address decides.
159
+ */
160
+ probeFromOuterNetwork: (spaceId, script) => docker(
161
+ ['run', '--rm', ...helperLabels, '--network', spaceResourceName(spaceId, ROLE_OUTER_NETWORK), SPACE_BASE_IMAGE, 'node', '-e', script],
162
+ { timeoutMs: 120_000 },
163
+ ),
164
+
142
165
  /**
143
166
  * A name of our own in the space's outer network, answered by Docker's embedded resolver with
144
167
  * an address in that bridge's subnet — a private range, and therefore one the corridor must
@@ -1,3 +1,5 @@
1
+ import net from 'node:net';
2
+
1
3
  import { SpaceError } from '../errors.js';
2
4
  import { createGatekeeperChannel } from '../gatekeeper-channel.js';
3
5
  import {
@@ -25,7 +27,8 @@ import {
25
27
  requireSpaceId,
26
28
  spaceResourceName,
27
29
  } from '../labels.js';
28
- import { SPACE_USER, TOOLS_MOUNT_PATH } from '../layout.js';
30
+ import { SPACE_CONNECT_COMMAND, SPACE_USER, TOOLS_MOUNT_PATH } from '../layout.js';
31
+ import { openCommandStream as openCommandStreamProcess } from '../run-command.js';
29
32
  import { createSpaceServerChannel, createSpaceToken } from '../space-server.js';
30
33
  import { CHANGE_TIMEOUT_MS, ROLLBACK_SETTLE_MS, createDockerEngine, entryLabels, entryName, isInterrupted, pause } from './docker-engine.js';
31
34
  import { createDockerTools } from './docker-tools.js';
@@ -57,7 +60,7 @@ function execRole(target) {
57
60
  throw new SpaceError('invalid_exec_target', `A command runs in the space or in its gatekeeper, not in '${target}'`);
58
61
  }
59
62
 
60
- export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource, wait = pause, now = () => new Date() }) {
63
+ export function createDockerPlace({ runCommand, openCommandStream = openCommandStreamProcess, dockerPath, owner, toolsSource, wait = pause, now = () => new Date() }) {
61
64
  requireOwner(owner);
62
65
 
63
66
  const engine = createDockerEngine({ runCommand, dockerPath });
@@ -307,7 +310,7 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
307
310
  await docker(['network', 'connect', outerNetwork, gatekeeperName], CHANGE_TIMEOUT_MS);
308
311
  await requireGatekeeperVerified(id, await inspectOwnContainer(id, gatekeeperName) ?? {});
309
312
  await docker(['start', gatekeeperName], CHANGE_TIMEOUT_MS);
310
- await gatekeeper.writeProgram(id);
313
+ await gatekeeper.writeProgram(id, { bindAddress: await innerAddressOf(id) });
311
314
  await gatekeeper.waitUntilReady(id);
312
315
  // Create, verify, then start: a container that fails the check never runs.
313
316
  await docker(buildSpaceCreateArgs({
@@ -388,6 +391,20 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
388
391
  // Starts that are under way in this process, by space id.
389
392
  const starting = new Map();
390
393
 
394
+ /**
395
+ * The gatekeeper's own address on the space's inner network, read from the runtime once the
396
+ * container runs: that is where its corridor and window listen, and nowhere else. An engine
397
+ * that reports none leaves the gatekeeper unstarted, which is the safe answer.
398
+ */
399
+ const innerAddressOf = async (spaceId) => {
400
+ const entry = await inspectOwnContainer(spaceId, spaceResourceName(spaceId, ROLE_GATEKEEPER));
401
+ const address = String(entry?.NetworkSettings?.Networks?.[spaceResourceName(spaceId, ROLE_NETWORK)]?.IPAddress ?? '');
402
+ if (net.isIP(address) === 0) {
403
+ throw new SpaceError('gatekeeper_address_unknown', `The runtime reports no address for the gatekeeper of space ${spaceId} on the space's network, so its listeners cannot be bound.`);
404
+ }
405
+ return address;
406
+ };
407
+
391
408
  /** The gatekeeper container of a space. It is never renamed, so there is no move to repair here. */
392
409
  const requireGatekeeperContainer = async (spaceId) => {
393
410
  const name = spaceResourceName(spaceId, ROLE_GATEKEEPER);
@@ -425,6 +442,17 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
425
442
  return [dockerPath, 'exec', '--interactive', '--user', SPACE_USER, spaceResourceName(spaceId, ROLE_SPACE)];
426
443
  };
427
444
 
445
+ /**
446
+ * A channel to the server inside the space: the bridge of `layout.js` over the argv of
447
+ * `execArgv`, so the same ownership and running checks come first, and the same process
448
+ * shape as a push carries the bytes. The space's network never sees it. The stream is a
449
+ * `CommandStream` of `run-command.js`, and the dispatcher's agent uses it as a socket.
450
+ */
451
+ const connect = async (spaceId) => {
452
+ const [file, ...args] = await execArgv(spaceId);
453
+ return openCommandStream(file, [...args, ...SPACE_CONNECT_COMMAND]);
454
+ };
455
+
428
456
  /**
429
457
  * The space stops first and its gatekeeper after it, so a space is never running while its
430
458
  * way out is not under the host's control. A stop of the gatekeeper that fails leaves the
@@ -456,7 +484,7 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
456
484
  await requireGatekeeperVerified(spaceId, container);
457
485
  await docker(['start', name], CHANGE_TIMEOUT_MS);
458
486
  }
459
- await gatekeeper.writeProgram(spaceId);
487
+ await gatekeeper.writeProgram(spaceId, { bindAddress: await innerAddressOf(spaceId) });
460
488
  await gatekeeper.waitUntilReady(spaceId);
461
489
  };
462
490
 
@@ -583,5 +611,5 @@ export function createDockerPlace({ runCommand, dockerPath, owner, toolsSource,
583
611
  return starting.get(spaceId);
584
612
  };
585
613
 
586
- return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, stop, start, remove, verify };
614
+ return { id: DOCKER_PLACE_ID, check, create, list, exec, execArgv, connect, stop, start, remove, verify };
587
615
  }
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import { SpaceError } from '../errors.js';
4
4
  import { GATEKEEPER_PROGRAM } from '../gatekeeper-channel.js';
5
5
  import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from '../labels.js';
6
- import { SPACE_ENVIRONMENT, SPACE_SERVER_COMMAND } from '../layout.js';
6
+ import { SPACE_CONNECT_COMMAND, SPACE_ENVIRONMENT, SPACE_SERVER_COMMAND } from '../layout.js';
7
7
  import { createRegistryToolsSource, toolsContentKey } from '../tools.js';
8
8
  import { SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
9
9
  import { bridgeNetworkEntry, createFakeDocker, hardenedContainerEntry, internalNetworkEntry } from './fake-docker.js';
@@ -27,7 +27,7 @@ const NOW = new Date('2026-09-20T08:00:00.000Z');
27
27
  /** A place on a plain runner, for the tests that wrap or replace the fake. */
28
28
  const placeOn = (runCommand, options = {}) => createDockerPlace({ runCommand, dockerPath: 'docker', owner: OWNER, toolsSource: SOURCE, now: () => NOW, ...options });
29
29
 
30
- const makePlace = (fake, owner = OWNER, toolsSource = SOURCE) => placeOn(fake.runCommand, { dockerPath: '/usr/bin/docker', owner, toolsSource, wait: fake.wait, now: fake.now });
30
+ const makePlace = (fake, owner = OWNER, toolsSource = SOURCE, options = {}) => placeOn(fake.runCommand, { dockerPath: '/usr/bin/docker', owner, toolsSource, wait: fake.wait, now: fake.now, ...options });
31
31
 
32
32
  const labelsFor = (role, { id = ID, owner = OWNER } = {}) => buildSpaceLabels({ ...SPEC, id, role, owner });
33
33
 
@@ -272,13 +272,21 @@ describe('docker place: create', () => {
272
272
  '--env', 'HOME=/tmp',
273
273
  SPACE_BASE_IMAGE,
274
274
  '/bin/sh', '-c',
275
- 'while [ ! -s /tmp/openchamber-gatekeeper/gatekeeper.cjs ]; do /bin/sleep 0.2; done; exec /usr/local/bin/node /tmp/openchamber-gatekeeper/gatekeeper.cjs 0.0.0.0 3128 8080 9099 300000 128 64 8',
275
+ // The bind host is the file the host writes after the start, holding the gatekeeper's own
276
+ // address on the space's network, never every interface: see "Known limits" of stage 2.
277
+ 'while [ ! -s /tmp/openchamber-gatekeeper/gatekeeper.cjs ]; do /bin/sleep 0.2; done; exec /usr/local/bin/node /tmp/openchamber-gatekeeper/gatekeeper.cjs /tmp/openchamber-gatekeeper/bind 3128 8080 9099 300000 128 64 8',
276
278
  ]);
277
279
  expect(create).toContain('openchamber.space.role=gatekeeper');
278
280
  expect(create).not.toContain('--mount');
279
281
  // The program travels on stdin, so it is in no argument list, and no secret is in one either.
280
282
  const write = fake.calls.find((call) => isExec('gatekeeper.cjs.new')(call.args));
281
283
  expect(write.options.stdin).toBe(GATEKEEPER_PROGRAM);
284
+ // The address the fake engine gives the gatekeeper on the inner network, read after the start
285
+ // and written into the bind file before the program.
286
+ expect(write.args.slice(-2)).toEqual(['sh', '172.19.0.2']);
287
+ expect(write.args.join(' ')).toContain('> /tmp/openchamber-gatekeeper/bind.new && mv /tmp/openchamber-gatekeeper/bind.new /tmp/openchamber-gatekeeper/bind && cat > /tmp/openchamber-gatekeeper/gatekeeper.cjs.new');
288
+ const order = fake.calls.map((call) => describeCall(call.args));
289
+ expect(order.indexOf(`start ${GATEKEEPER}`)).toBeLessThan(order.indexOf('exec write program'));
282
290
  expect(write.args[4]).toBe(GATEKEEPER);
283
291
  });
284
292
 
@@ -833,6 +841,37 @@ describe('docker place: exec, stop, start, verify', () => {
833
841
  await expect(makePlace(createFakeDocker({ resources: aside })).execArgv(ID)).rejects.toMatchObject({ code: 'space_move_unfinished' });
834
842
  });
835
843
 
844
+ // Added in stage 4a. `connect` runs the bridge of layout.js over the argv of `execArgv`, through
845
+ // the injected stream opener, so the same checks come first and no process starts in a unit test.
846
+ it('connects through the bridge inside the space container, over the exec argv, and changes nothing', async () => {
847
+ const fake = createFakeDocker({ resources: spaceResources() });
848
+ const opened = [];
849
+ const stream = { destroyed: false };
850
+ const place = makePlace(fake, OWNER, SOURCE, { openCommandStream: (file, args) => { opened.push([file, ...args]); return stream; } });
851
+
852
+ expect(await place.connect(ID)).toBe(stream);
853
+ expect(opened).toEqual([['/usr/bin/docker', 'exec', '--interactive', '--user', '1000:1000', CONTAINER, ...SPACE_CONNECT_COMMAND]]);
854
+ expect(SPACE_CONNECT_COMMAND.slice(0, 2)).toEqual(['/usr/local/bin/node', '-e']);
855
+ expect(SPACE_CONNECT_COMMAND.slice(-2)).toEqual(['127.0.0.1', '27600']);
856
+ expect(SPACE_CONNECT_COMMAND.join(' ')).not.toMatch(/\n/);
857
+ expect(changes(fake)).toEqual([]);
858
+ });
859
+
860
+ it('connects to no stopped space, no stranger\'s container, no space of another installation, none that is missing or mid-move', async () => {
861
+ const opened = [];
862
+ const connectWith = (fake) => makePlace(fake, OWNER, SOURCE, { openCommandStream: (...args) => { opened.push(args); return {}; } }).connect(ID);
863
+ await expect(connectWith(createFakeDocker({ resources: spaceResources({ running: false }) }))).rejects.toMatchObject({ code: 'space_not_running' });
864
+ const stranger = hardenedContainerEntry({ name: CONTAINER, labels: {}, network: NETWORK, volumes: [] });
865
+ await expect(connectWith(createFakeDocker({ resources: [{ kind: 'container', name: CONTAINER, entry: stranger }] }))).rejects.toMatchObject({ code: 'space_not_ours' });
866
+ await expect(connectWith(createFakeDocker({ resources: spaceResources({ owner: 'install-b' }) }))).rejects.toMatchObject({ code: 'space_not_ours' });
867
+ await expect(connectWith(createFakeDocker())).rejects.toMatchObject({ code: 'space_not_found' });
868
+ const aside = spaceResources({ running: false }).map((resource) => (resource.name === CONTAINER
869
+ ? { ...resource, name: `${CONTAINER}-old`, entry: { ...resource.entry, Name: `/${CONTAINER}-old` } }
870
+ : resource));
871
+ await expect(connectWith(createFakeDocker({ resources: aside }))).rejects.toMatchObject({ code: 'space_move_unfinished' });
872
+ expect(opened).toEqual([]);
873
+ });
874
+
836
875
  it('stops the space, starts the same container again, and waits for its server', async () => {
837
876
  const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
838
877
  const place = makePlace(fake);