@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
@@ -63,9 +63,11 @@ export const SPACE_TOKEN_PATH = `${SPACE_TOKEN_DIRECTORY}/token`;
63
63
  // The gatekeeper, the space's only way out. It sits on the space's internal network under this
64
64
  // name and on the space's own outer network, and nothing else joins either of them.
65
65
  export const GATEKEEPER_ALIAS = 'gatekeeper';
66
- // The two listeners the space may see. They bind every interface of the gatekeeper, because
67
- // the space's network is the point of them.
68
- const GATEKEEPER_BIND_HOST = '0.0.0.0';
66
+ // The two listeners the space may see. They bind the gatekeeper's own address on the space's
67
+ // network and nothing else: on a Linux Docker host every local process can reach the bridge the
68
+ // outer network is, and a window listening there would spend the user's key for anyone on the
69
+ // machine. The address is known only once the container runs, so the host writes it into this
70
+ // file beside the program, and the container command names the file.
69
71
  export const GATEKEEPER_CORRIDOR_PORT = 3128;
70
72
  export const GATEKEEPER_WINDOW_PORT = 8080;
71
73
  // The control channel. It binds the gatekeeper's own loopback, so only an `exec` from the host
@@ -74,14 +76,22 @@ export const GATEKEEPER_WINDOW_PORT = 8080;
74
76
  export const GATEKEEPER_CONTROL_HOST = '127.0.0.1';
75
77
  export const GATEKEEPER_CONTROL_PORT = 9099;
76
78
 
77
- // Where the space sends its traffic. The window's own URL, `http://gatekeeper:8080/model/<grant>`,
78
- // belongs to whoever writes a provider configuration, which no stage does yet.
79
+ // Where the space sends its traffic: the corridor, and for a grant the window under that grant's id.
79
80
  const GATEKEEPER_CORRIDOR_URL = `http://${GATEKEEPER_ALIAS}:${GATEKEEPER_CORRIDOR_PORT}`;
81
+ /** The URL inside the space that reaches a grant's upstream through the window. */
82
+ export const spaceWindowUrl = (grantId) => `http://${GATEKEEPER_ALIAS}:${GATEKEEPER_WINDOW_PORT}/model/${grantId}`;
80
83
 
81
84
  // The gatekeeper's program arrives over `exec` on stdin, into the container's tmpfs. It is far
82
85
  // larger than the tools filler, and a `node -e` argument of that size was never tried on Windows.
83
86
  export const GATEKEEPER_PROGRAM_DIRECTORY = '/tmp/openchamber-gatekeeper';
84
87
  export const GATEKEEPER_PROGRAM_PATH = `${GATEKEEPER_PROGRAM_DIRECTORY}/gatekeeper.cjs`;
88
+ export const GATEKEEPER_BIND_PATH = `${GATEKEEPER_PROGRAM_DIRECTORY}/bind`;
89
+
90
+ // OpenCode's global configuration inside a space, where the host writes the provider
91
+ // configuration that sends model calls through the window. The agent can change it; the
92
+ // gatekeeper is what enforces, this only cooperates.
93
+ export const SPACE_OPENCODE_CONFIG_DIRECTORY = `${SPACE_HOME}/.config/opencode`;
94
+ export const SPACE_OPENCODE_CONFIG_PATH = `${SPACE_OPENCODE_CONFIG_DIRECTORY}/opencode.json`;
85
95
 
86
96
  const IMAGE_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
87
97
 
@@ -103,8 +113,9 @@ export const IMAGE_TIMEOUT = '/usr/bin/timeout';
103
113
  export const IMAGE_ONLY_PATH = `PATH=${IMAGE_PATH};`;
104
114
 
105
115
  /**
106
- * The environment of a space. The password of the server inside and
107
- * OPENCODE_AUTH_CONTENT never go here: container env is readable through `inspect`.
116
+ * The environment of a space. Neither the password of the server inside nor any credential ever
117
+ * goes here: container env is readable through `inspect`, and the hardening check allows no
118
+ * variable beyond these and the base image's own.
108
119
  */
109
120
  export const SPACE_ENVIRONMENT = Object.freeze({
110
121
  HOME: SPACE_HOME,
@@ -145,6 +156,29 @@ const SERVER_SCRIPT = [
145
156
  /** The command of a space container. A fixed script, nothing in it varies per space. */
146
157
  export const SPACE_SERVER_COMMAND = Object.freeze([IMAGE_SH, '-c', SERVER_SCRIPT]);
147
158
 
159
+ // The bridge of `connect`: runs inside the space over `docker exec --interactive`, joins its stdin
160
+ // and stdout to the loopback port of the server inside, and ends when either side ends. It
161
+ // leaves only after its last write to stdout has gone out: an exit on the socket's close would
162
+ // drop what is still buffered. The two standard streams are Node's own, which cope with a pipe
163
+ // that is full; a plain file stream on the descriptor failed with a system error there,
164
+ // measured on macOS with a four-megabyte answer. One line, as every fixed program that travels
165
+ // as an argument. It uses `net`, which no proxy variable of the space touches, and reads nothing
166
+ // from the space but its two arguments.
167
+ export const CONNECT_BRIDGE_PROGRAM = [
168
+ 'const net = require("node:net");',
169
+ 'const [host, port] = process.argv.slice(1);',
170
+ 'const socket = net.connect({ host, port: Number(port) });',
171
+ 'const leave = (code) => process.stdout.write("", () => process.exit(code));',
172
+ 'socket.on("error", (error) => { process.stderr.write(String(error.code || error.message)); leave(1); });',
173
+ 'socket.on("connect", () => { process.stdin.pipe(socket); socket.pipe(process.stdout, { end: false }); });',
174
+ 'socket.on("close", () => leave(0));',
175
+ 'process.stdin.on("error", () => socket.destroy());',
176
+ 'process.stdout.on("error", () => socket.destroy());',
177
+ ].join(' ');
178
+
179
+ /** The command that `connect` runs inside a space: the bridge to the server inside, on the image's Node. */
180
+ export const SPACE_CONNECT_COMMAND = Object.freeze([IMAGE_NODE, '-e', CONNECT_BRIDGE_PROGRAM, SPACE_SERVER_HOST, String(SPACE_SERVER_PORT)]);
181
+
148
182
  /**
149
183
  * The environment of a gatekeeper. It holds no secret and never will: grants arrive on the
150
184
  * stdin of an `exec` and live in the program's memory. HOME points into the tmpfs, because the
@@ -170,7 +204,7 @@ const GATEKEEPER_CONTROL_CONNECTIONS = 8;
170
204
  const GATEKEEPER_SCRIPT = [
171
205
  `while [ ! -s ${GATEKEEPER_PROGRAM_PATH} ]; do ${IMAGE_SLEEP} 0.2; done;`,
172
206
  `exec ${IMAGE_NODE} ${GATEKEEPER_PROGRAM_PATH}`,
173
- GATEKEEPER_BIND_HOST,
207
+ GATEKEEPER_BIND_PATH,
174
208
  String(GATEKEEPER_CORRIDOR_PORT),
175
209
  String(GATEKEEPER_WINDOW_PORT),
176
210
  String(GATEKEEPER_CONTROL_PORT),
@@ -1,5 +1,5 @@
1
1
  import { SpaceError } from './errors.js';
2
- import { createSpaceId, hashProjectDirectory, normalizeSpaceName } from './labels.js';
2
+ import { createSpaceId, hashProjectDirectory, normalizeSpaceName, requireSpaceId } from './labels.js';
3
3
 
4
4
  // Memory limit of a new space. Swap adds nothing on top of it.
5
5
  const DEFAULT_SPACE_MEMORY_BYTES = 4 * 1024 * 1024 * 1024;
@@ -29,10 +29,11 @@ export function createSpaceManager({ registry, now = () => new Date() }) {
29
29
  return result;
30
30
  };
31
31
 
32
- const createSpace = async ({ placeId, projectDirectory, name }) => {
32
+ // `id` may be given, so a caller that announces a creation before it is done names the same space.
33
+ const createSpace = async ({ placeId, projectDirectory, name, id = createSpaceId() }) => {
33
34
  const place = requirePlace(placeId);
34
35
  const spec = {
35
- id: createSpaceId(),
36
+ id: requireSpaceId(id),
36
37
  name: normalizeSpaceName(name),
37
38
  project: hashProjectDirectory(projectDirectory),
38
39
  created: now().toISOString(),
@@ -29,6 +29,12 @@ describe('createSpaceManager', () => {
29
29
  expect(await manager.listSpaces({ placeId: 'memory' })).toEqual([{ ...listedFields, state: 'running', orphans: [], damaged: false, missing: [] }]);
30
30
  });
31
31
 
32
+ it('takes the id a caller announced before the creation, and refuses one that is not a space id', async () => {
33
+ const manager = managerFor(createMemoryPlace());
34
+ expect((await manager.createSpace({ ...REQUEST, id: 'a1b2c3d4e5f6' })).id).toBe('a1b2c3d4e5f6');
35
+ await expect(manager.createSpace({ ...REQUEST, id: 'not-an-id' })).rejects.toMatchObject({ code: 'invalid_space_id' });
36
+ });
37
+
32
38
  it('names the place that does not exist', async () => {
33
39
  const manager = managerFor(createMemoryPlace());
34
40
 
@@ -0,0 +1,412 @@
1
+ // Code out, from a real space on a real Docker daemon. Runs only with OPENCHAMBER_TEST_DOCKER=1.
2
+ // The bait comes in through code in, a scripted agent works inside, and its result comes out over
3
+ // `git fetch` through `docker exec` and is applied both ways. The escape attempts of TESTING.md that
4
+ // belong to code out run from inside, each with its control. Every test makes a space of its own
5
+ // and a repository of its own, so each runs alone as well as in order, and removes its space.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import { createTestHost, forConfig, hostState, makeBait, removeTestHosts, unexpectedChanges } from '../code-in-bait.js';
14
+ import { createSpaceId, hashProjectDirectory, spaceResourceName } from '../labels.js';
15
+ import { runCommand } from '../run-command.js';
16
+ import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
17
+
18
+ const CREATE_TIMEOUT_MS = 25 * 60_000;
19
+ const WIN = process.platform === 'win32';
20
+ const IMAGE_PATH = 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;';
21
+ // The same sizing as the hanging tests of code in: a measured `docker exec` round trip, with a 6 s
22
+ // host timeout and a 5 s margin as the floor.
23
+ const hangTiming = (roundTripMs) => {
24
+ const hostTimeoutMs = Math.max(6000, 3 * roundTripMs + 3000);
25
+ const innerMarginSeconds = Math.max(5, Math.ceil((4 * roundTripMs + 4000) / 1000));
26
+ return { roundTripMs, hostTimeoutMs, innerMarginSeconds, innerLimitMs: hostTimeoutMs + innerMarginSeconds * 1000 };
27
+ };
28
+ // The docker calls between `started` and the fetch, where the limit inside begins to count: `execArgv`
29
+ // inspects, and the snapshot's `exec` inspects and runs.
30
+ const DOCKER_CALLS_BEFORE_FETCH = 3;
31
+
32
+ // What the agent can plant for the fetch out: git in the space reads its global config, and
33
+ // `uploadpack.packObjectsHook` from there runs in place of pack-objects. This one never ends.
34
+ const PLANT_HANGING_PACK_HOOK = [
35
+ IMAGE_PATH,
36
+ 'printf "#!/bin/sh\\nexec /bin/sleep 1000\\n" > "$HOME/hang-pack" &&',
37
+ 'chmod 755 "$HOME/hang-pack" &&',
38
+ 'git config --global uploadpack.packObjectsHook "$HOME/hang-pack"',
39
+ ].join(' ');
40
+ // A `packObjectsHook` that sends a pack without end: a header that promises a million objects,
41
+ // then one incompressible megabyte after another. Only the host's size cap stops it before its deadline.
42
+ const ENDLESS_PACK = [
43
+ "const zlib = require('node:zlib'); const crypto = require('node:crypto');",
44
+ "process.stdout.on('error', () => process.exit(0));",
45
+ "const head = Buffer.alloc(12); head.write('PACK'); head.writeUInt32BE(2, 4); head.writeUInt32BE(1000000, 8); process.stdout.write(head);",
46
+ 'const blob = () => { let size = 1 << 20; const bytes = [(3 << 4) | (size & 15)]; size >>= 4;',
47
+ 'while (size) { bytes[bytes.length - 1] |= 0x80; bytes.push(size & 0x7f); size >>= 7; }',
48
+ 'return Buffer.concat([Buffer.from(bytes), zlib.deflateSync(crypto.randomBytes(1 << 20))]); };',
49
+ "const pump = () => { while (process.stdout.write(blob())) {} process.stdout.once('drain', pump); }; pump();",
50
+ ].join(' ');
51
+ const PLANT_ENDLESS_PACK_HOOK = [
52
+ IMAGE_PATH,
53
+ 'printf "%s" "$1" > "$HOME/endless-pack.js" &&',
54
+ 'printf "#!/bin/sh\\nexec /usr/local/bin/node %s\\n" "$HOME/endless-pack.js" > "$HOME/endless-pack" &&',
55
+ 'chmod 755 "$HOME/endless-pack" &&',
56
+ 'git config --global uploadpack.packObjectsHook "$HOME/endless-pack"',
57
+ ].join(' ');
58
+ const COUNT_ENDLESS = 'PATH=/usr/bin:/bin; n=0; for p in /proc/[0-9]*; do tr "\\000" " " < "$p/cmdline" 2>/dev/null | grep -q "^/usr/local/bin/node /home/space/endless-pack.js" && n=$((n+1)); done; echo $n';
59
+ const COUNT_SLEEPERS = 'PATH=/usr/bin:/bin; n=0; for p in /proc/[0-9]*; do tr "\\000" " " < "$p/cmdline" 2>/dev/null | grep -q "^/bin/sleep 1000" && n=$((n+1)); done; echo $n';
60
+ // The processes of a fetch out: the image's `timeout`, the upload-pack under it and its pack-objects.
61
+ // Anchored, so the counting script's own command line never counts.
62
+ const COUNT_UPLOADERS = 'PATH=/usr/bin:/bin; n=0; for p in /proc/[0-9]*; do tr "\\000" " " < "$p/cmdline" 2>/dev/null | grep -qE "^(/usr/bin/timeout |/usr/bin/git upload-pack |git pack-objects )" && n=$((n+1)); done; echo $n';
63
+
64
+ const pause = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
65
+
66
+ /** How many host processes name this container on their command line. The docker CLI of an exec always does. */
67
+ async function hostProcessesNaming(container) {
68
+ if (WIN) {
69
+ const script = `@(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${container}*' -and $_.ProcessId -ne $PID }).Count`;
70
+ const result = await runCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
71
+ return Number(result.stdout.trim());
72
+ }
73
+ const result = await runCommand('ps', ['-A', '-o', 'command=']);
74
+ return result.stdout.split('\n').filter((command) => command.includes(container)).length;
75
+ }
76
+
77
+ /** Waits up to two seconds for the host processes that name the container to be gone. It must finish before the limit inside. */
78
+ async function hostProcessesGone(container, innerLimitAt) {
79
+ let count = await hostProcessesNaming(container);
80
+ for (let attempt = 0; attempt < 8 && count > 0; attempt += 1) {
81
+ await pause(250);
82
+ count = await hostProcessesNaming(container);
83
+ }
84
+ expect(Date.now(), 'the host check ran into the limit inside the space and proves nothing').toBeLessThan(innerLimitAt);
85
+ return count === 0;
86
+ }
87
+
88
+ describe.skipIf(!LIVE_DOCKER_ENABLED)('code out: docker (live)', () => {
89
+ let place;
90
+ let dispose = async () => {};
91
+ let host;
92
+ let timing;
93
+
94
+ const inside = (spaceId, script, args = []) => place.exec(spaceId, ['/bin/sh', '-c', `${IMAGE_PATH} ${script}`, 'sh', ...args], { timeoutMs: 120_000 });
95
+
96
+ /**
97
+ * A space of its own with a bait repository of its own brought in. `history: false` leaves the
98
+ * space's repository shallow. `agent(script)` runs a script in the space's project folder as the agent.
99
+ */
100
+ const spaceWithCode = async (name, { history = true } = {}) => {
101
+ const spec = { id: createSpaceId(), name, project: hashProjectDirectory(`/code/out/${name}`), created: new Date().toISOString(), memoryBytes: 1024 * 1024 * 1024 };
102
+ await place.create(spec);
103
+ const bait = makeBait(host, { name: `bait ${spec.id}` });
104
+ const codeIn = host.codeIn(place);
105
+ const came = await codeIn.bringCodeIn({ repository: bait.repo, spaceId: spec.id });
106
+ if (history) await codeIn.sendHistory({ repository: bait.repo, spaceId: spec.id, spacePath: came.spacePath, base: came.base });
107
+ const agent = async (script, args = []) => {
108
+ const result = await inside(spec.id, `cd "$1" && shift && ${script}`, [came.spacePath, ...args]);
109
+ expect(result.code, `the agent's script failed: ${result.stderr}`).toBe(0);
110
+ return result.stdout;
111
+ };
112
+ const codeOut = host.codeOut(place);
113
+ const request = (extra = {}) => ({ repository: bait.repo, spaceId: spec.id, spacePath: came.spacePath, ...extra });
114
+ return { ...bait, id: spec.id, came, agent, codeOut, request, out: (extra) => codeOut.bringCodeOut(request(extra)), remove: () => place.remove(spec.id) };
115
+ };
116
+
117
+ beforeAll(async () => {
118
+ ({ place, dispose } = createLiveDockerPlace());
119
+ host = createTestHost({ ownHome: false });
120
+ // The slowest of three round trips of `docker exec` sizes the hanging test. The space is removed afterwards.
121
+ const probe = { id: createSpaceId(), name: 'Code out probe', project: hashProjectDirectory('/code/out/probe'), created: new Date().toISOString(), memoryBytes: 512 * 1024 * 1024 };
122
+ await place.create(probe);
123
+ let roundTripMs = 0;
124
+ for (let sample = 0; sample < 3; sample += 1) {
125
+ const sampleStarted = Date.now();
126
+ await place.exec(probe.id, ['/bin/true']);
127
+ roundTripMs = Math.max(roundTripMs, Date.now() - sampleStarted);
128
+ }
129
+ timing = hangTiming(roundTripMs);
130
+ await place.remove(probe.id);
131
+ }, CREATE_TIMEOUT_MS);
132
+
133
+ afterAll(async () => {
134
+ await dispose();
135
+ removeTestHosts();
136
+ }, CREATE_TIMEOUT_MS);
137
+
138
+ it('brings the agent\'s work out and applies it as a branch and as uncommitted changes', async () => {
139
+ const space = await spaceWithCode('Code out');
140
+ const { repo, g } = space;
141
+ await space.agent('printf "committed\\n" > committed.txt && printf "#!/bin/sh\\necho tool\\n" > tool.sh && chmod 755 tool.sh && git add committed.txt tool.sh && git commit --quiet -m "the agent commits"');
142
+ const agentHead = (await space.agent('git rev-parse HEAD')).trim();
143
+ await space.agent('printf "uncommitted\\n" >> tracked-to-edit.txt && printf "untracked\\n" > untracked-in-the-space.txt && git add untracked-in-the-space.txt');
144
+ const statusInside = await space.agent('git status --porcelain=v1 --untracked-files=all');
145
+ const before = hostState(repo);
146
+
147
+ const out = await space.out();
148
+ expect(g(['rev-parse', `${out.result}^`]).trim()).toBe(agentHead);
149
+ expect(g(['log', '-1', '--format=%s', out.result]).trim()).toBe('openchamber: uncommitted changes from the space');
150
+ expect(out.changedPaths).toBe(4);
151
+ expect(out.nestedRepositories).toEqual({ count: 0, paths: [] });
152
+ expect(out.unmerged).toEqual({ count: 0, paths: [] });
153
+ expect(await space.agent('git status --porcelain=v1 --untracked-files=all')).toBe(statusInside);
154
+ expect(unexpectedChanges(before, hostState(repo), { spaceIds: [space.id], codeOut: true })).toEqual([]);
155
+
156
+ const statusBefore = g(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']);
157
+ await space.codeOut.applyAsBranch({ repository: repo, spaceId: space.id, branch: 'from-the-space' });
158
+ expect(g(['rev-parse', 'refs/heads/from-the-space']).trim()).toBe(out.result);
159
+ expect(g(['ls-tree', 'refs/heads/from-the-space', 'tool.sh']).split(' ')[0]).toBe('100755');
160
+ expect(g(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all'])).toBe(statusBefore);
161
+
162
+ expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: space.id })).toEqual({
163
+ status: 'applied', appliedPaths: 4, remembered: true, nestedRepositories: { count: 0, paths: [] }, conflicted: { count: 0, paths: [] },
164
+ });
165
+ const status = g(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']);
166
+ // The branch the space started from is unchanged; what came out is in the working tree, uncommitted.
167
+ expect(status).toContain('?? committed.txt\n');
168
+ expect(status).toContain('?? tool.sh\n');
169
+ expect(status).toContain('?? untracked-in-the-space.txt\n');
170
+ expect(status).toContain('MM tracked-to-edit.txt\n');
171
+ expect(fs.readFileSync(path.join(repo, 'tracked-to-edit.txt'), 'utf8')).toBe('one\ntwo staged\nthree unstaged\nuncommitted\n');
172
+ // The exec bit is lost on Windows, where git does not track it in a working tree.
173
+ if (!WIN) expect(fs.statSync(path.join(repo, 'tool.sh')).mode & 0o777).toBe(0o755);
174
+
175
+ // The agent works on, and a second apply brings only what is new since the first one.
176
+ await space.agent('printf "more\\n" > second-round.txt');
177
+ const second = await space.out();
178
+ expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: space.id })).toMatchObject({ status: 'applied', appliedPaths: 1, remembered: true });
179
+ expect(g(['rev-parse', `refs/openchamber/spaces/${space.id}/applied`]).trim()).toBe(second.result);
180
+ expect(fs.readFileSync(path.join(repo, 'second-round.txt'), 'utf8')).toBe('more\n');
181
+ expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: space.id })).toEqual({ status: 'nothing_to_apply' });
182
+
183
+ // The user and the agent change the same line: from then on this space is applied as a branch.
184
+ await space.agent('printf "more, from the agent\\n" > second-round.txt');
185
+ const third = await space.out();
186
+ fs.writeFileSync(path.join(repo, 'second-round.txt'), 'more, from the user\n');
187
+ const refused = await space.codeOut.applyAsChanges({ repository: repo, spaceId: space.id }).catch((error) => error);
188
+ expect(refused).toMatchObject({ code: 'changes_do_not_apply' });
189
+ expect(refused.message).toMatch(/applied as a branch/);
190
+ expect(fs.readFileSync(path.join(repo, 'second-round.txt'), 'utf8')).toBe('more, from the user\n');
191
+ const closed = await space.codeOut.applyAsChanges({ repository: repo, spaceId: space.id }).catch((error) => error);
192
+ expect(closed).toMatchObject({ code: 'changes_route_closed' });
193
+ // The branch still holds the whole work, the rounds already applied included.
194
+ const branch = await space.codeOut.applyAsBranch({ repository: repo, spaceId: space.id, branch: 'the-whole-work' });
195
+ expect(branch.commit).toBe(third.result);
196
+ expect(g(['ls-tree', '-r', '--name-only', 'refs/heads/the-whole-work']).split('\n')).toEqual(expect.arrayContaining(['committed.txt', 'second-round.txt', 'tool.sh']));
197
+ await space.remove();
198
+ }, CREATE_TIMEOUT_MS);
199
+
200
+ it('comes out of a space whose history has not arrived, and the user\'s repository stays complete', async () => {
201
+ const space = await spaceWithCode('Code out, shallow', { history: false });
202
+ expect((await space.agent('git rev-parse --is-shallow-repository')).trim()).toBe('true');
203
+ await space.agent('printf "from a shallow space\\n" > shallow.txt');
204
+ const out = await space.out();
205
+ expect(space.g(['cat-file', 'blob', `${out.result}:shallow.txt`])).toBe('from a shallow space\n');
206
+ expect(fs.existsSync(path.join(space.repo, '.git', 'shallow'))).toBe(false);
207
+ await space.remove();
208
+ }, CREATE_TIMEOUT_MS);
209
+
210
+ // TESTING.md: "Make the host run something through apply: git hooks, tags, refs outside the
211
+ // quarantine namespace."
212
+ it('runs nothing the agent planted on the host, and takes no tag and no ref but the result', async () => {
213
+ const space = await spaceWithCode('Code out, planted');
214
+ const hostMarker = path.join(host.root, `planted-ran-${space.id}`);
215
+ // The planted hook marks the host's own folder, and only when that folder exists, which it does on
216
+ // the host and never inside: a Windows path given to `touch` inside is a relative name full of
217
+ // backslashes, and the file it made there travelled out in the result as part of the work. Forward
218
+ // slashes, so the sh of Git for Windows reads the path as it is. Where it ran inside, it also marks
219
+ // the repository's own git folder, which is never part of a snapshot.
220
+ const hook = [
221
+ '#!/bin/sh',
222
+ `m="${forConfig(hostMarker)}"`,
223
+ '[ -d "$(dirname "$m")" ] && touch "$m"',
224
+ 'touch "$(git rev-parse --git-dir)/planted-hook-ran"',
225
+ 'exit 0',
226
+ '',
227
+ ].join('\n');
228
+ // Hooks and a filter in the space's own repository, a hooks folder and a filter in its tree, tags and refs.
229
+ const plant = [
230
+ 'printf "%s" "$1" > .git/hooks/reference-transaction && chmod 755 .git/hooks/reference-transaction &&',
231
+ 'git config filter.planted.smudge "touch .git/planted-filter-ran; cat" && git config filter.planted.clean cat &&',
232
+ 'mkdir -p .githooks && cp .git/hooks/reference-transaction .githooks/post-checkout &&',
233
+ 'printf "* filter=planted\\n" > .gitattributes && printf "[core]\\n\\thooksPath = .githooks\\n" > .gitconfig &&',
234
+ 'git add --all && git commit --quiet -m planted &&',
235
+ 'git tag planted-light && git tag -a -m annotated planted-annotated && git update-ref refs/heads/planted-branch HEAD &&',
236
+ 'rm -f .git/planted-hook-ran',
237
+ ].join(' ');
238
+ await space.agent(plant, [hook]);
239
+ // Nothing of the marks landed in the project folder inside, where the snapshot would take it.
240
+ expect(await space.agent('git status --porcelain --untracked-files=all')).toBe('');
241
+ const refsBefore = space.g(['for-each-ref', '--format=%(refname)']).split('\n').filter(Boolean);
242
+
243
+ await space.out();
244
+ // The control: the planted hook really ran, inside, when the snapshot wrote its ref.
245
+ expect(await space.agent('[ -e .git/planted-hook-ran ] && echo ran; true')).toBe('ran\n');
246
+ expect(space.g(['for-each-ref', '--format=%(refname)']).split('\n').filter(Boolean)).toEqual([...refsBefore, `refs/openchamber/spaces/${space.id}/result`].sort());
247
+ await space.codeOut.applyAsBranch({ repository: space.repo, spaceId: space.id, branch: 'from-the-space' });
248
+ await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: space.id });
249
+ expect(fs.existsSync(path.join(space.repo, '.githooks', 'post-checkout'))).toBe(true);
250
+ expect(fs.existsSync(hostMarker)).toBe(false);
251
+ // The control, on the host: the planted hook that came out is a working hook that marks the host
252
+ // when git is told to use it, so its silence above is the host not running it, not a dud.
253
+ const probe = spawnSync('git', ['-C', space.repo, '-c', 'core.hooksPath=.githooks', 'checkout', '--quiet', '-b', 'probe'], { env: host.environment, encoding: 'utf8', windowsHide: true });
254
+ expect(probe.status, probe.stderr).toBe(0);
255
+ expect(fs.existsSync(hostMarker)).toBe(true);
256
+ await space.remove();
257
+ }, CREATE_TIMEOUT_MS);
258
+
259
+ // A hook of the agent's can reach its parent git's stdout through /proc and write a commit id of its
260
+ // own there, then move the result ref to that commit. The snapshot sends everything it runs to
261
+ // /dev/null, so what the host reads first is still the snapshot's own id, and the moved ref is
262
+ // refused rather than taken. The verifier who found this showed that removing the redirect makes
263
+ // the host take the planted commit.
264
+ it('does not let a hook print into the report through its parent\'s stdout', async () => {
265
+ const space = await spaceWithCode('Code out, stdout hook');
266
+ const planted = (await space.agent([
267
+ 'blob=$(printf "planted\\n" | git hash-object -w --stdin) &&',
268
+ 'tree=$(printf "100644 blob %s\\tplanted.txt\\n" "$blob" | git mktree) &&',
269
+ 'git commit-tree "$tree" -p HEAD -m planted',
270
+ ].join(' '))).trim();
271
+ const hook = [
272
+ '#!/bin/sh',
273
+ '[ "$1" = committed ] || exit 0',
274
+ 'marker="$(git rev-parse --git-dir)/stdout-hook-ran"',
275
+ '[ -e "$marker" ] && exit 0',
276
+ ': > "$marker"',
277
+ `printf '%s\\n' ${planted} > /proc/$PPID/fd/1`,
278
+ `( sleep 1; git update-ref refs/openchamber/result ${planted} ) &`,
279
+ 'exit 0',
280
+ '',
281
+ ].join('\n');
282
+ await space.agent('printf "%s" "$1" > .git/hooks/reference-transaction && chmod 755 .git/hooks/reference-transaction && printf "work\\n" > work.txt', [hook]);
283
+ const before = hostState(space.repo);
284
+ const failure = await space.out().catch((error) => error);
285
+ // The control: the hook ran on the snapshot's own ref write.
286
+ expect(await space.agent('[ -e .git/stdout-hook-ran ] && echo ran; true')).toBe('ran\n');
287
+ expect(failure).toMatchObject({ code: 'result_ref_missing', details: { step: 'fetch into the quarantine' } });
288
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] })).toEqual([]);
289
+ await space.remove();
290
+ }, CREATE_TIMEOUT_MS);
291
+
292
+ // TESTING.md: "Get tree paths such as `.git/hooks/x`, `.GIT`, `..`, or `a/../../x` past the host's
293
+ // object checks." Each tree is built inside with the space's own git, past its path checks.
294
+ it('refuses hostile tree names at the quarantine, and brings the same kind of tree out with an ordinary name', async () => {
295
+ const space = await spaceWithCode('Code out, hostile trees');
296
+ const planted = [
297
+ 'blob=$(printf "planted\\n" | git hash-object -w --stdin) &&',
298
+ 'entry() { /usr/local/bin/node -e "process.stdout.write(Buffer.concat([Buffer.from(process.argv[1] + \' \' + process.argv[2] + String.fromCharCode(0)), Buffer.from(process.argv[3], \'hex\')]))" "$1" "$2" "$3" | git hash-object -t tree --literally -w --stdin; } &&',
299
+ 'if [ "$1" = ".git/hooks/x" ]; then t=$(entry 100644 x $blob) && t=$(entry 40000 hooks $t) && tree=$(entry 40000 .git $t); else tree=$(entry 100644 "$1" $blob); fi &&',
300
+ 'git update-ref HEAD $(git commit-tree $tree -p HEAD -m planted)',
301
+ ].join(' ');
302
+ const head = (await space.agent('git rev-parse HEAD')).trim();
303
+ const before = hostState(space.repo);
304
+ for (const name of ['.git', '.GIT', 'git~1', '.git ', '..', 'a/../../x', '.git/hooks/x']) {
305
+ await space.agent(planted, [name]);
306
+ const failure = await space.out().catch((error) => error);
307
+ expect(failure, name).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine' } });
308
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] }), name).toEqual([]);
309
+ await space.agent('git update-ref HEAD "$1"', [head]);
310
+ }
311
+ // The control: built the same way, an ordinary name comes out. The planted commit is the parent
312
+ // of the result, whose own tree is the working tree the snapshot took.
313
+ await space.agent(planted, ['ordinary.txt']);
314
+ const out = await space.out();
315
+ expect(space.g(['cat-file', 'blob', `${out.result}^:ordinary.txt`])).toBe('planted\n');
316
+ await space.remove();
317
+ }, CREATE_TIMEOUT_MS);
318
+
319
+ // TESTING.md: "write through a symlink or into `.git/` when applied as a patch". `.git/` cannot come
320
+ // out at all, see above; a folder of the space where the user has a link is refused at apply.
321
+ it('writes nothing through a link the user has where the space made a folder, and brings a link to outside in as a link only', async () => {
322
+ const space = await spaceWithCode('Code out, links');
323
+ const outside = path.join(host.root, `outside-${space.id}`);
324
+ fs.mkdirSync(outside);
325
+ await space.agent('mkdir -p cache && printf "through the link\\n" > cache/planted.txt && ln -s /etc/passwd link-outside');
326
+ const out = await space.out();
327
+ // The control: the result does hold the file and the link.
328
+ expect(space.g(['cat-file', 'blob', `${out.result}:cache/planted.txt`])).toBe('through the link\n');
329
+ expect(space.g(['ls-tree', out.result, 'link-outside']).split(' ')[0]).toBe('120000');
330
+ fs.appendFileSync(path.join(space.repo, '.git', 'info', 'exclude'), 'cache\n');
331
+ fs.symlinkSync(outside, path.join(space.repo, 'cache'), WIN ? 'junction' : 'dir');
332
+ const before = hostState(space.repo);
333
+ // Refused by the host's own look at the folders on the way, not by git: Git for Windows writes
334
+ // through a junction.
335
+ await expect(space.codeOut.applyAsChanges({ repository: space.repo, spaceId: space.id })).rejects.toMatchObject({ code: 'changes_blocked_by_link', details: { path: 'cache' } });
336
+ expect(fs.readdirSync(outside)).toEqual([]);
337
+ // The one thing a refused apply writes: from now on this space is applied as a branch.
338
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] })).toEqual([`.git/refs/openchamber/spaces/${space.id}/changes-closed`]);
339
+ await space.remove();
340
+ }, CREATE_TIMEOUT_MS);
341
+
342
+ // TESTING.md: "Hang the result fetch forever ... The host's timeout ... must end it, and the process
343
+ // left inside the space must be cleaned up."
344
+ it('ends a fetch that the space never answers at the host timeout, with no host process left and nothing inside after its limit', async () => {
345
+ const space = await spaceWithCode('Code out, hanging');
346
+ await space.agent('printf "work\\n" > work.txt');
347
+ expect((await inside(space.id, PLANT_HANGING_PACK_HOOK)).code).toBe(0);
348
+ const before = hostState(space.repo);
349
+ const started = Date.now();
350
+ const failure = await space.out({ timeoutMs: timing.hostTimeoutMs, innerMarginSeconds: timing.innerMarginSeconds }).catch((error) => error);
351
+ expect(failure).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine', cause: 'command_timeout' } });
352
+ // The control: the planted hook really held the fetch inside, and still does.
353
+ expect(Number((await inside(space.id, COUNT_SLEEPERS)).stdout)).toBeGreaterThan(0);
354
+ expect(await hostProcessesGone(spaceResourceName(space.id, 'space'), started + timing.innerLimitMs)).toBe(true);
355
+ const bound = started + timing.innerLimitMs + (DOCKER_CALLS_BEFORE_FETCH + 2) * timing.roundTripMs + 3000;
356
+ let emptied = false;
357
+ while (!emptied && Date.now() <= bound + 500) {
358
+ emptied = (await inside(space.id, COUNT_SLEEPERS)).stdout === '0\n';
359
+ if (!emptied) await pause(500);
360
+ }
361
+ expect(emptied, 'the fetch inside outlived the limit inside the space').toBe(true);
362
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] })).toEqual([]);
363
+ await space.remove();
364
+ }, CREATE_TIMEOUT_MS);
365
+
366
+ // TESTING.md: "... or fill the host's disk with it. The host's ... size cap must end" it. A space
367
+ // that sends without end is stopped by the cap long before the deadline, which is what shows that
368
+ // the cap watches the transfer while it runs, not only once it is over.
369
+ it('stops a space that sends without end at the size cap, long before the deadline, with nothing left on the host or inside', async () => {
370
+ const space = await spaceWithCode('Code out, endless');
371
+ await space.agent('printf "work\\n" > work.txt');
372
+ expect((await inside(space.id, PLANT_ENDLESS_PACK_HOOK, [ENDLESS_PACK])).code).toBe(0);
373
+ const before = hostState(space.repo);
374
+ const started = Date.now();
375
+ const failure = await space.out({ maxTransferBytes: 4 * 1024 * 1024, timeoutMs: 90_000 }).catch((error) => error);
376
+ expect(failure).toMatchObject({ code: 'result_transfer_too_large', details: { step: 'fetch into the quarantine' } });
377
+ expect(Date.now() - started).toBeLessThan(60_000);
378
+ expect(await hostProcessesGone(spaceResourceName(space.id, 'space'), started + 95_000)).toBe(true);
379
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] })).toEqual([]);
380
+ // Nothing of it runs inside once the limit there has passed.
381
+ let emptied = false;
382
+ while (!emptied && Date.now() <= started + 100_000) {
383
+ emptied = (await inside(space.id, COUNT_ENDLESS)).stdout === '0\n';
384
+ if (!emptied) await pause(500);
385
+ }
386
+ expect(emptied).toBe(true);
387
+ await space.remove();
388
+ }, CREATE_TIMEOUT_MS);
389
+
390
+ // TESTING.md: "... or fill the host's disk with it. The host's ... size cap must end" it.
391
+ it('stops a fetch that passes the size cap, with no host process left, and brings the same work out with room for it', async () => {
392
+ const space = await spaceWithCode('Code out, large');
393
+ await space.agent('head -c 16777216 /dev/urandom > large.bin');
394
+ const before = hostState(space.repo);
395
+ const failure = await space.out({ maxTransferBytes: 4 * 1024 * 1024, timeoutMs: 120_000 }).catch((error) => error);
396
+ expect(failure).toMatchObject({ code: 'result_transfer_too_large', details: { step: 'fetch into the quarantine' } });
397
+ expect(await hostProcessesGone(spaceResourceName(space.id, 'space'), Date.now() + 60_000)).toBe(true);
398
+ expect(unexpectedChanges(before, hostState(space.repo), { spaceIds: [] })).toEqual([]);
399
+ // Nothing of the fetch runs inside once the limit there has passed.
400
+ const bound = Date.now() + 125_000;
401
+ let emptied = false;
402
+ while (!emptied && Date.now() <= bound) {
403
+ emptied = (await inside(space.id, COUNT_UPLOADERS)).stdout === '0\n';
404
+ if (!emptied) await pause(500);
405
+ }
406
+ expect(emptied).toBe(true);
407
+ // The control: with the default cap the same result comes out.
408
+ const out = await space.out();
409
+ expect(out.changedBytes).toBe(16 * 1024 * 1024);
410
+ await space.remove();
411
+ }, CREATE_TIMEOUT_MS);
412
+ });
@@ -1,6 +1,8 @@
1
1
  // The contract every place must pass. Stage 8 to 10 places run this same suite.
2
2
  // `setup` resolves `{ place, dispose }`. `dispose` cleans up and may assert that nothing is left.
3
3
 
4
+ import http from 'node:http';
5
+
4
6
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
7
 
6
8
  import { createSpaceId, hashProjectDirectory } from '../labels.js';
@@ -23,6 +25,17 @@ export function runPlaceContractSuite(title, { enabled = true, setup }) {
23
25
 
24
26
  const listed = async () => (await place.list()).find((space) => space.id === spec.id);
25
27
 
28
+ /** One HTTP request over a channel that `connect` gave, as the dispatcher's agent makes it. */
29
+ const requestOver = (stream, path) => new Promise((resolve, reject) => {
30
+ const request = http.request({ path, headers: { host: '127.0.0.1' }, createConnection: () => stream }, (response) => {
31
+ const chunks = [];
32
+ response.on('data', (chunk) => chunks.push(chunk));
33
+ response.on('end', () => resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
34
+ });
35
+ request.on('error', reject);
36
+ request.end();
37
+ });
38
+
26
39
  beforeAll(async () => {
27
40
  ({ place, dispose } = await setup());
28
41
  });
@@ -111,11 +124,29 @@ export function runPlaceContractSuite(title, { enabled = true, setup }) {
111
124
  expect(hostname.stdout).not.toBe((await place.exec(spec.id, ['cat', '/etc/hostname'], { target: 'gatekeeper' })).stdout);
112
125
  });
113
126
 
127
+ // Added in stage 4a. `connect` is the dispatcher's way to the server inside: a stream that
128
+ // carries HTTP to it and nothing else. It must reach the server of this space, and it
129
+ // must end cleanly when the caller lets go, so an agent can hold and reuse it.
130
+ it('connects to the server inside the space', async () => {
131
+ const stream = await place.connect(spec.id);
132
+ const answer = await requestOver(stream, '/health');
133
+ expect(answer.status).toBe(200);
134
+ expect(JSON.parse(answer.body)).toMatchObject({ isOpenCodeReady: true });
135
+ const closed = new Promise((resolve) => stream.once('close', resolve));
136
+ stream.destroy();
137
+ await closed;
138
+ });
139
+
114
140
  it('stops the space and lists it as exited', async () => {
115
141
  await place.stop(spec.id);
116
142
  expect(await listed()).toMatchObject({ state: 'exited' });
117
143
  });
118
144
 
145
+ // Added in stage 4a, for the same reason as the argv: a stopped space has no server to reach.
146
+ it('connects to no stopped space', async () => {
147
+ await expect(place.connect(spec.id)).rejects.toMatchObject({ code: 'space_not_running' });
148
+ });
149
+
119
150
  // Added in stage 3a. git starts the argv itself, and a stopped container answers it only with
120
151
  // the runtime's own words, so a place refuses a stopped space here with a code of its own.
121
152
  it('hands out no argv for a stopped space', async () => {
@@ -149,6 +180,11 @@ export function runPlaceContractSuite(title, { enabled = true, setup }) {
149
180
  await expect(place.execArgv(spec.id)).rejects.toThrow();
150
181
  });
151
182
 
183
+ // Added in stage 4a, with the same reasoning as the argv.
184
+ it('connects to no space that is gone', async () => {
185
+ await expect(place.connect(spec.id)).rejects.toThrow();
186
+ });
187
+
152
188
  it('treats removing a missing space as done', async () => {
153
189
  expect(await place.remove(spec.id)).toEqual({ removed: [], failed: [] });
154
190
  });