@vantran-se/openchamber-web 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +4 -4
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import { findGatekeeperHardeningViolations, findHardeningViolations, requireMemoryBytes } from './hardening.js';
4
4
  import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from './labels.js';
5
+ import { SPACE_ENVIRONMENT } from './layout.js';
5
6
  import { bridgeNetworkEntry, hardenedContainerEntry, internalNetworkEntry } from './places/fake-docker.js';
6
7
 
7
8
  const ID = 'a1b2c3d4e5f6';
@@ -78,8 +79,11 @@ describe('findHardeningViolations', () => {
78
79
  ['user', { ...goodContainer(), Config: { User: '' } }],
79
80
  ['user', { ...goodContainer(), Config: { User: '0:0' } }],
80
81
  ['environment', withEnv(['OPENCHAMBER_UI_PASSWORD=secret'])],
81
- ['environment', withEnv(['OPENCODE_AUTH_CONTENT={}'])],
82
82
  ['environment', withEnv(['OPENCHAMBER_UI_PASSWORD='])],
83
+ // OpenCode 2 takes a provider key from its environment, directly or inside its config text.
84
+ ['environment', withEnv(['OPENAI_API_KEY=x'])],
85
+ ['environment', withEnv(['OPENCODE_CONFIG_CONTENT={"provider":{"groq":{"options":{"apiKey":"x"}}}}'])],
86
+ ['environment', withEnv(['OPENCODE_CLI_CONFIG_CONTENT={}'])],
83
87
  ['read_only', withHost({ ReadonlyRootfs: false })],
84
88
  ['privileged', withHost({ Privileged: true })],
85
89
  ['cap_drop', withHost({ CapDrop: ['NET_RAW'] })],
@@ -143,8 +147,18 @@ describe('findHardeningViolations', () => {
143
147
  expect(checksFor({ container: withHost({ Binds: ['/var/run/docker.sock:/var/run/docker.sock'] }) })).toEqual(['binds', 'runtime_socket']);
144
148
  });
145
149
 
146
- it('accepts harmless variables next to the ones a space gets', () => {
147
- expect(checksFor({ container: withEnv(['OPENCODE_DISABLE_AUTOUPDATE=1', 'NOT_OPENCHAMBER_UI_PASSWORD=x']) })).toEqual([]);
150
+ it('accepts exactly the variables a space gets and the base image sets', () => {
151
+ const full = withEnv([
152
+ ...Object.entries(SPACE_ENVIRONMENT).map(([name, value]) => `${name}=${value}`),
153
+ 'NODE_VERSION=22.23.2',
154
+ 'YARN_VERSION=1.22.22',
155
+ ]);
156
+ expect(checksFor({ container: full })).toEqual([]);
157
+ });
158
+
159
+ it('refuses any other variable, whatever its name says', () => {
160
+ const violations = findHardeningViolations({ spaceId: ID, owner: OWNER, container: withEnv(['NOT_OPENCHAMBER_UI_PASSWORD=x']), network: goodNetwork(), toolsVolume: goodToolsVolume() });
161
+ expect(violations).toEqual([{ check: 'environment', message: 'The container environment holds variables nobody set for it: NOT_OPENCHAMBER_UI_PASSWORD' }]);
148
162
  });
149
163
 
150
164
  describe('tools mount', () => {
@@ -295,6 +309,7 @@ describe('findGatekeeperHardeningViolations', () => {
295
309
 
296
310
  it.each([
297
311
  ['gatekeeper_environment', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, Env: ['HOME=/tmp', 'OPENCHAMBER_UI_PASSWORD=secret'] } }],
312
+ ['gatekeeper_environment', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, Env: ['HOME=/tmp', 'OPENAI_API_KEY=x'] } }],
298
313
  ['gatekeeper_user', { ...goodGatekeeper(), Config: { ...goodGatekeeper().Config, User: '0:0' } }],
299
314
  ['gatekeeper_read_only', withGatekeeperHost({ ReadonlyRootfs: false })],
300
315
  ['gatekeeper_privileged', withGatekeeperHost({ Privileged: true })],
@@ -44,6 +44,9 @@ const REMOVED_VARIABLES = new Set([
44
44
  'GIT_NOGLOB_PATHSPECS',
45
45
  'GIT_ICASE_PATHSPECS',
46
46
  'GIT_PROTOCOL_FROM_USER',
47
+ // Reads every attribute from a tree instead of the working tree. Code out reads the working tree back
48
+ // with exactly the attributes it holds, and code in snapshots it with them.
49
+ 'GIT_ATTR_SOURCE',
47
50
  'GIT_AUTHOR_NAME',
48
51
  'GIT_AUTHOR_EMAIL',
49
52
  'GIT_AUTHOR_DATE',
@@ -101,18 +104,18 @@ const subcommandOf = (args) => {
101
104
  * `run(directory, args, options)` resolves `{ code, stdout, stderr }` for any exit code, and
102
105
  * `output(directory, args, options)` resolves stdout and rejects with `git_command_failed` for a
103
106
  * non-zero one. Both run `git -C <directory> ...` through `runCommand`, with an argv and no shell.
104
- * `options.env` adds variables for one call, such as `GIT_INDEX_FILE`. `options.killTree` goes to
105
- * `runCommand` unchanged.
107
+ * `options.env` adds variables for one call, such as `GIT_INDEX_FILE`. `options.keepTail`,
108
+ * `options.killTree`, `options.keepAtExit` and `options.signal` go to `runCommand` unchanged.
106
109
  *
107
110
  * `gitPath` is the git program, `environment` the environment to start from.
108
111
  */
109
112
  export function createHostGit({ runCommand, gitPath = 'git', environment = process.env }) {
110
113
  const baseEnvironment = hostGitEnvironment(environment);
111
114
 
112
- const run = (directory, args, { env = {}, stdin, timeoutMs = QUERY_TIMEOUT_MS, maxOutputBytes = QUERY_MAX_OUTPUT_BYTES, killTree = false } = {}) => runCommand(
115
+ const run = (directory, args, { env = {}, stdin, timeoutMs = QUERY_TIMEOUT_MS, maxOutputBytes = QUERY_MAX_OUTPUT_BYTES, keepTail = false, killTree = false, keepAtExit = false, signal } = {}) => runCommand(
113
116
  gitPath,
114
117
  ['-C', directory, ...args],
115
- { env: { ...baseEnvironment, ...env }, stdin, timeoutMs, maxOutputBytes, killTree },
118
+ { env: { ...baseEnvironment, ...env }, stdin, timeoutMs, maxOutputBytes, keepTail, killTree, keepAtExit, signal },
116
119
  );
117
120
 
118
121
  const output = async (directory, args, options) => {
@@ -12,7 +12,7 @@ const REMOVED = [
12
12
  'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0', 'GIT_CONFIG_KEY_12', 'GIT_CONFIG_VALUE_12',
13
13
  'GIT_TRACE', 'GIT_TRACE_PACKET', 'GIT_TRACE_PACK_ACCESS', 'GIT_TRACE_PERFORMANCE', 'GIT_TRACE_SETUP', 'GIT_TRACE_CURL',
14
14
  'GIT_TRACE_SHALLOW', 'GIT_TRACE2', 'GIT_TRACE2_EVENT', 'GIT_TRACE2_PERF', 'GIT_TRACE2_CONFIG_PARAMS',
15
- 'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
15
+ 'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS', 'GIT_ATTR_SOURCE',
16
16
  ];
17
17
  const ADDED = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
18
18
  const mixedCase = (name) => name.toLowerCase().replace(/^git/, 'Git');
@@ -86,7 +86,8 @@ describe('createHostGit', () => {
86
86
  it('runs git -C with an argv, the cleaned environment and the call options', async () => {
87
87
  const { calls, runCommand } = recordingRunner({ code: 0, stdout: 'out\n', stderr: '' });
88
88
  const git = createHostGit({ runCommand, gitPath: '/opt/git/bin/git', environment: { PATH: '/usr/bin', GIT_DIR: '/elsewhere' } });
89
- expect(await git.output('/repo with space', ['write-tree'], { env: { GIT_INDEX_FILE: '/tmp/index' }, killTree: true })).toBe('out\n');
89
+ const { signal } = new AbortController();
90
+ expect(await git.output('/repo with space', ['write-tree'], { env: { GIT_INDEX_FILE: '/tmp/index' }, keepTail: true, killTree: true, keepAtExit: true, signal })).toBe('out\n');
90
91
  expect(calls).toEqual([{
91
92
  file: '/opt/git/bin/git',
92
93
  args: ['-C', '/repo with space', 'write-tree'],
@@ -95,7 +96,10 @@ describe('createHostGit', () => {
95
96
  stdin: undefined,
96
97
  timeoutMs: 120_000,
97
98
  maxOutputBytes: 4 * 1024 * 1024,
99
+ keepTail: true,
98
100
  killTree: true,
101
+ keepAtExit: true,
102
+ signal,
99
103
  },
100
104
  }]);
101
105
  });
@@ -0,0 +1,310 @@
1
+ // What the host server builds when the isolated-spaces switch is on, and nothing of it when it
2
+ // is off: the place, the manager, the dispatcher, the WebSocket forwarder, the session index
3
+ // with the event connection of every space, and the hooks the rest of the server takes.
4
+ //
5
+ // `server/index.js` reads the switch at start and changes it live through the switch route.
6
+ // While it is off this module is never imported for its effect: no place, no manager, no
7
+ // route, no `docker`.
8
+
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import { z } from 'zod';
14
+
15
+ import { createCodeIn } from './code-in.js';
16
+ import { createCodeOut } from './code-out.js';
17
+ import { createSpaceDispatcher } from './dispatcher.js';
18
+ import { createGatekeeperChannel } from './gatekeeper-channel.js';
19
+ import { createHostGit } from './host-git.js';
20
+ import { createSpaceJourney } from './journey.js';
21
+ import { hashProjectDirectory } from './labels.js';
22
+ import { spaceProjectPath } from './layout.js';
23
+ import { createSpaceManager } from './manager.js';
24
+ import { createSpaceRecords } from './space-records.js';
25
+ import { createSpaceEventSources } from './space-events.js';
26
+ import { createSpaceOpenCode } from './space-opencode.js';
27
+ import { createSpaceSessionIndex, mergeSessionLists } from './space-sessions.js';
28
+ import { createSpaceWebSocketForwarder } from './websocket.js';
29
+ import { createDockerPlace } from './places/docker.js';
30
+ import { createPlaceRegistry } from './places/registry.js';
31
+ import { openCommandStream as openCommandStreamProcess, runCommand as runCommandProcess } from './run-command.js';
32
+ import { createSpaceServerChannel } from './space-server.js';
33
+ import { createRegistryToolsSource, readHostToolVersions } from './tools.js';
34
+
35
+ const OWNER_FILE = path.join('spaces', 'owner');
36
+
37
+ // How often the host reads its list of spaces to follow their event streams, and how long a
38
+ // read serves the merged session list before it is repeated.
39
+ const FOLLOW_INTERVAL_MS = 15_000;
40
+ const LIST_TTL_MS = 2_000;
41
+ // A space's session list is read in pages; more than this many is reported as partial.
42
+ const SESSION_PAGE_LIMIT = 100;
43
+ const SESSION_MAX_PAGES = 10;
44
+ const SESSION_LIST_TIMEOUT_MS = 10_000;
45
+ const MAX_SESSION_LIST_BYTES = 8 * 1024 * 1024;
46
+ // The cursor of a next page, as the server inside names it; anything else ends the read.
47
+ const nextCursorSchema = z.string().min(1);
48
+
49
+ /**
50
+ * The installation id that labels this host's spaces, so two installations that share a Docker
51
+ * daemon never see each other's. Made once, at the first start with the switch on, and kept in
52
+ * the data directory. It is a random token and names nothing about the machine.
53
+ */
54
+ export function readOrCreateOwner(dataDir) {
55
+ const file = path.join(dataDir, OWNER_FILE);
56
+ try {
57
+ const existing = fs.readFileSync(file, 'utf8').trim();
58
+ if (/^[a-z0-9]{16,64}$/.test(existing)) return existing;
59
+ } catch {
60
+ // Made below.
61
+ }
62
+ const owner = crypto.randomBytes(12).toString('hex');
63
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
64
+ fs.writeFileSync(file, `${owner}\n`, { encoding: 'utf8', mode: 0o600 });
65
+ return owner;
66
+ }
67
+
68
+ /**
69
+ * `dataDir` is the host's data directory, `dockerPath` the docker CLI to run and `gitPath` the
70
+ * host git that moves code in and out, with `hostEnvironment` as its environment: git starts
71
+ * `docker exec` itself, so the PATH in it must find docker. `place` replaces the Docker place,
72
+ * for the tests; `runCommand` and `openCommandStream` are the two ways this module starts a
73
+ * process, injectable for the same reason. `listProjectDirectories` answers the host's registered
74
+ * project paths, so a space's project label can be resolved to the project it was made for;
75
+ * without it every space is marked as of an unknown project.
76
+ */
77
+ export function createSpacesHost({
78
+ dataDir,
79
+ dockerPath = 'docker',
80
+ gitPath = 'git',
81
+ hostEnvironment = process.env,
82
+ listProjectDirectories = async () => [],
83
+ runCommand = runCommandProcess,
84
+ openCommandStream = openCommandStreamProcess,
85
+ place = null,
86
+ logger = console,
87
+ now = Date.now,
88
+ setTimer = setInterval,
89
+ clearTimer = clearInterval,
90
+ }) {
91
+ const dockerPlace = place ?? createDockerPlace({
92
+ runCommand,
93
+ openCommandStream,
94
+ dockerPath,
95
+ owner: readOrCreateOwner(dataDir),
96
+ toolsSource: createRegistryToolsSource(readHostToolVersions()),
97
+ });
98
+ const registry = createPlaceRegistry([dockerPlace]);
99
+ registry.seal();
100
+ const manager = createSpaceManager({ registry });
101
+ const serverInside = createSpaceServerChannel({ exec: dockerPlace.exec });
102
+ const gatekeeper = createGatekeeperChannel({ exec: dockerPlace.exec });
103
+ const git = createHostGit({ runCommand, gitPath, environment: hostEnvironment });
104
+ const codeIn = createCodeIn({ git, place: dockerPlace });
105
+ const codeOut = createCodeOut({ git, place: dockerPlace });
106
+ const records = createSpaceRecords({ dataDir, logger });
107
+ const spaceOpenCode = createSpaceOpenCode({ exec: dockerPlace.exec });
108
+
109
+ const listSpaces = () => manager.listSpaces({ placeId: dockerPlace.id });
110
+ const dispatcher = createSpaceDispatcher({
111
+ logger,
112
+ transport: {
113
+ listSpaceIds: async () => (await listSpaces()).map((space) => space.id),
114
+ connect: (spaceId) => dockerPlace.connect(spaceId),
115
+ readToken: (spaceId) => serverInside.readToken(spaceId),
116
+ },
117
+ });
118
+
119
+ const index = createSpaceSessionIndex({ logger });
120
+ let events = null;
121
+ let unsubscribeHostEvents = null;
122
+ let followTimer = null;
123
+ let known = { spaces: [], readAt: -Infinity };
124
+ let listing = null;
125
+ // When each space's session list was last read; within the TTL the accepted list is served again.
126
+ const listReadAt = new Map();
127
+
128
+ /**
129
+ * Which registered project a space was made for, by the label's hash of the project path. A
130
+ * failed read of the projects, or a project since removed, leaves the space with no project.
131
+ */
132
+ const resolveProjects = async (spaces) => {
133
+ if (spaces.length === 0) return new Map();
134
+ let directories = [];
135
+ try {
136
+ directories = await listProjectDirectories();
137
+ } catch (error) {
138
+ logger.warn?.(`[spaces] could not read the projects: ${error?.code ?? error?.message ?? error}`);
139
+ }
140
+ const byHash = new Map(directories.map((directory) => [hashProjectDirectory(directory), directory]));
141
+ return new Map(spaces.map((space) => [space.id, byHash.get(space.project) ?? null]));
142
+ };
143
+
144
+ /**
145
+ * This host's spaces, each with its name and the project it was made for, read again when
146
+ * older than the TTL. A failed read keeps the last ones.
147
+ */
148
+ const knownSpaces = async () => {
149
+ if (now() - known.readAt < LIST_TTL_MS) return known.spaces;
150
+ if (!listing) {
151
+ listing = listSpaces()
152
+ .then(async (spaces) => {
153
+ const projects = await resolveProjects(spaces);
154
+ known = {
155
+ spaces: spaces.map((space) => {
156
+ const projectDirectory = projects.get(space.id) ?? null;
157
+ return {
158
+ id: space.id,
159
+ name: space.name,
160
+ projectDirectory,
161
+ directory: projectDirectory === null ? null : spaceProjectPath(space.id, projectDirectory),
162
+ };
163
+ }),
164
+ readAt: now(),
165
+ };
166
+ })
167
+ .catch((error) => { logger.warn?.(`[spaces] could not list the spaces: ${error?.code ?? error?.message ?? error}`); })
168
+ .finally(() => { listing = null; });
169
+ }
170
+ await listing;
171
+ return known.spaces;
172
+ };
173
+ const spaceIds = async () => (await knownSpaces()).map((space) => space.id);
174
+
175
+ const follow = async () => {
176
+ const ids = await spaceIds();
177
+ events?.sync(ids);
178
+ };
179
+ /** The list is read again at once, and the event connections follow it: for a space just made or removed. */
180
+ const refresh = () => {
181
+ known = { spaces: known.spaces, readAt: -Infinity };
182
+ return follow();
183
+ };
184
+
185
+ let hub = null;
186
+ const journey = createSpaceJourney({
187
+ manager,
188
+ place: dockerPlace,
189
+ gatekeeper,
190
+ codeIn,
191
+ codeOut,
192
+ records,
193
+ spaceOpenCode,
194
+ listProjectDirectories,
195
+ // A key named by an environment variable is read from the host's own environment, now, and
196
+ // its value is kept nowhere (decision 5).
197
+ readHostSecret: (name) => hostEnvironment[name],
198
+ announce: (spaceId, payload) => { hub?.injectEvent({ payload, directory: 'global', spaceId }); },
199
+ onSpacesChanged: () => { void refresh().catch(() => {}); },
200
+ logger,
201
+ });
202
+
203
+ const readBody = (response, cap) => new Promise((resolve, reject) => {
204
+ const chunks = [];
205
+ let size = 0;
206
+ response.on('data', (chunk) => {
207
+ size += chunk.length;
208
+ if (size > cap) { response.destroy(); reject(new Error(`the list exceeds ${cap} bytes`)); return; }
209
+ chunks.push(chunk);
210
+ });
211
+ response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
212
+ response.on('error', reject);
213
+ });
214
+
215
+ /** One space's whole session list, page by page, or as much of it as the page cap allows. */
216
+ const readSpaceSessions = async (spaceId) => {
217
+ const records = [];
218
+ let cursor = null;
219
+ for (let page = 0; page < SESSION_MAX_PAGES; page += 1) {
220
+ const query = new URLSearchParams({ limit: String(SESSION_PAGE_LIMIT) });
221
+ if (cursor !== null) query.set('cursor', cursor);
222
+ const response = await dispatcher.requestInside(spaceId, { path: `/api/session?${query}`, headers: { accept: 'application/json' }, timeoutMs: SESSION_LIST_TIMEOUT_MS });
223
+ if (response.statusCode !== 200) { response.resume(); throw new Error(`status ${response.statusCode}`); }
224
+ const payload = JSON.parse(await readBody(response, MAX_SESSION_LIST_BYTES));
225
+ const data = Array.isArray(payload) ? payload : payload?.data;
226
+ if (!Array.isArray(data)) throw new Error('the list is not a list');
227
+ records.push(...data);
228
+ const next = nextCursorSchema.safeParse(payload?.cursor?.next);
229
+ if (!next.success || data.length === 0) return { records, complete: true };
230
+ cursor = next.data;
231
+ }
232
+ return { records, complete: false };
233
+ };
234
+
235
+ /**
236
+ * The host's session list with every space's after it. Each reachable space is asked once,
237
+ * all of them at the same time; one that does not answer keeps its last known list, marked
238
+ * stale. A host list without spaces goes back exactly as it came.
239
+ */
240
+ const mergeSessionList = async (hostPayload) => {
241
+ const hostRecords = Array.isArray(hostPayload) ? hostPayload : hostPayload?.data;
242
+ if (Array.isArray(hostRecords)) index.observeHostRecords(hostRecords);
243
+ const spaces = await knownSpaces();
244
+ if (spaces.length === 0) return hostPayload;
245
+ await Promise.all(spaces.map(async ({ id: spaceId }) => {
246
+ if (now() - (listReadAt.get(spaceId) ?? -Infinity) < LIST_TTL_MS) return;
247
+ try {
248
+ const { records, complete } = await readSpaceSessions(spaceId);
249
+ index.acceptSpaceList(spaceId, records, { complete });
250
+ listReadAt.set(spaceId, now());
251
+ } catch (error) {
252
+ logger.warn?.(`[spaces] the session list of space ${spaceId} did not come: ${error?.code ?? error?.message ?? error}`);
253
+ index.markUnreachable(spaceId);
254
+ }
255
+ }));
256
+ // In the place's order, so the merged list reads the same from one call to the next.
257
+ const answers = new Map(index.snapshot().map((entry) => [entry.spaceId, entry]));
258
+ return mergeSessionLists(hostPayload, spaces.flatMap((space) => {
259
+ const answer = answers.get(space.id);
260
+ return answer === undefined ? [] : [{ ...answer, name: space.name, projectDirectory: space.projectDirectory, directory: space.directory }];
261
+ }));
262
+ };
263
+
264
+ let sockets = null;
265
+
266
+ return {
267
+ manager,
268
+ dispatcher,
269
+ index,
270
+ journey,
271
+ /** The places a space can be made on, for the funnel. */
272
+ places: () => registry.list(),
273
+ /** The dispatcher, to mount after the API auth gate and before every route that reads a directory. */
274
+ middleware: dispatcher.middleware,
275
+ /**
276
+ * Makes the WebSocket forwarder, with the host's own auth and origin checks. `upgradeHandler`
277
+ * then takes the upgrades under the prefix, and nothing before this call.
278
+ */
279
+ prepareUpgrades: ({ uiAuthController, isRequestOriginAllowed }) => {
280
+ sockets = createSpaceWebSocketForwarder({ dispatcher, connect: (spaceId) => dockerPlace.connect(spaceId), uiAuthController, isRequestOriginAllowed, logger });
281
+ },
282
+ upgradeHandler: (...args) => sockets?.upgradeHandler(...args),
283
+ /** Follows the spaces: an event connection for each, into the host's hub, and the host's own ids from its events. */
284
+ startEvents: (globalEventHub) => {
285
+ hub = globalEventHub;
286
+ events = createSpaceEventSources({ requestInside: dispatcher.requestInside, index, hub: globalEventHub, logger, now });
287
+ unsubscribeHostEvents = globalEventHub.subscribeEvent((event) => { if (event.spaceId === null) index.observeHostEvent(event.payload); });
288
+ followTimer = setTimer(() => { void follow(); }, FOLLOW_INTERVAL_MS);
289
+ followTimer?.unref?.();
290
+ return follow();
291
+ },
292
+ /** For the proxy: the merged session list, or the host's own when no space exists. */
293
+ mergeSessionList,
294
+ /** For the body parsers: a request to a space keeps its body for the space. */
295
+ skipsBodyParsing: (req) => dispatcher.isSpaceRequestPath(req.path),
296
+ /** For the directory gate: the reason a directory is refused on the host, or null. */
297
+ refuseDirectory: (candidate) => (dispatcher.isSpaceDirectory(candidate)
298
+ ? 'A directory under /spaces/ belongs to an isolated space and is addressed as /api/spaces/<id>/... only'
299
+ : null),
300
+ close: () => {
301
+ if (followTimer !== null) clearTimer(followTimer);
302
+ followTimer = null;
303
+ unsubscribeHostEvents?.();
304
+ unsubscribeHostEvents = null;
305
+ events?.close();
306
+ sockets?.close();
307
+ dispatcher.close();
308
+ },
309
+ };
310
+ }