@vantran-se/openchamber-web 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +4 -4
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -0,0 +1,173 @@
1
+ // What code in and code out share: the host git session that starts nothing of the user's, the one
2
+ // git version floor of the feature, the work tree check, the `ext::` URL, and the time limits of a
3
+ // transfer. Both directions follow docs/isolated-spaces/stage-0/e4-git-over-exec.md.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ import { SpaceError } from './errors.js';
9
+ import { isGitVersionAtLeast, parseGitVersion } from './host-git.js';
10
+ import { requireSpaceId } from './labels.js';
11
+
12
+ // The oldest host git the feature works with. `fetch --no-write-fetch-head` first appears in the
13
+ // git-fetch manual of 2.29.0 (the 2.28.0 page does not have it), and without it code out would write
14
+ // `FETCH_HEAD` into the user's `.git`. Code in needs 2.27.0: `git init --object-format`. Every other
15
+ // option either direction uses is older. See DOCUMENTATION.md, "Host git".
16
+ const GIT_VERSION_FLOOR = Object.freeze({ major: 2, minor: 29, patch: 0 });
17
+ const GIT_VERSION_FLOOR_TEXT = '2.29';
18
+
19
+ // Commits the host or the fixed scripts write carry a fixed identity, so a user without `user.name`
20
+ // can still create a space and bring its work out.
21
+ export const SNAPSHOT_IDENTITY = Object.freeze({
22
+ GIT_AUTHOR_NAME: 'OpenChamber',
23
+ GIT_AUTHOR_EMAIL: 'spaces@openchamber.invalid',
24
+ GIT_COMMITTER_NAME: 'OpenChamber',
25
+ GIT_COMMITTER_EMAIL: 'spaces@openchamber.invalid',
26
+ });
27
+
28
+ // A git `ext::` URL splits on spaces itself and cannot carry these at all.
29
+ export const EXT_UNCARRIABLE = /[\x00-\x1f\x7f]/;
30
+
31
+ // The limit inside is the host's limit plus this margin, so it never ends a transfer the host still
32
+ // waits for, and a transfer the host killed leaves nothing running inside once it passes.
33
+ export const INNER_MARGIN_SECONDS = 5;
34
+ const MAX_INNER_MARGIN_SECONDS = 3600;
35
+ const MIN_TIMEOUT_MS = 1_000;
36
+ const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
37
+
38
+ export const spaceRefPrefix = (spaceId) => `refs/openchamber/spaces/${requireSpaceId(spaceId)}/`;
39
+ export const startRef = (spaceId) => `${spaceRefPrefix(spaceId)}start`;
40
+
41
+ /** One line of git output, without its line end. */
42
+ export const line = (text) => text.replace(/\r?\n$/, '');
43
+ export const zeroObjectId = (objectFormat) => '0'.repeat(objectFormat === 'sha256' ? 64 : 40);
44
+ export const objectIdPattern = (objectFormat) => (objectFormat === 'sha256' ? /^[0-9a-f]{64}$/ : /^[0-9a-f]{40}$/);
45
+ export const innerSeconds = (timeoutMs, marginSeconds) => Math.ceil(timeoutMs / 1000) + marginSeconds;
46
+
47
+ /** The host's limit on a transfer, in whole milliseconds from a second to a day. */
48
+ export const requireTimeout = (value) => {
49
+ if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
50
+ throw new SpaceError('invalid_timeout', `The time limit of a transfer is a whole number of milliseconds from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}`);
51
+ }
52
+ return value;
53
+ };
54
+
55
+ /** The margin between the host's limit and the one inside, in whole seconds: a length, and nothing else. */
56
+ export const requireInnerMargin = (value) => {
57
+ if (!Number.isInteger(value) || value < 1 || value > MAX_INNER_MARGIN_SECONDS) {
58
+ throw new SpaceError('invalid_inner_margin', `The margin of the limit inside the space is a whole number of seconds from 1 to ${MAX_INNER_MARGIN_SECONDS}`);
59
+ }
60
+ return value;
61
+ };
62
+
63
+ /**
64
+ * A git `ext::` URL for an argv. git-remote-ext splits the text after `ext::` on spaces itself, so a
65
+ * space inside an argument is written `% ` and a percent `%%`, as its documentation says. The docker
66
+ * CLI on Windows can live under a path with spaces. An empty argument or one with a control
67
+ * character cannot be carried, and is refused. The URL goes to git as one argv element, never
68
+ * through a shell.
69
+ */
70
+ export function buildExtUrl(argv) {
71
+ if (!Array.isArray(argv) || argv.length === 0) {
72
+ throw new SpaceError('invalid_ext_argument', 'A command for git is a non-empty list of arguments');
73
+ }
74
+ const escaped = argv.map((argument) => {
75
+ const text = String(argument);
76
+ if (text === '' || EXT_UNCARRIABLE.test(text)) {
77
+ throw new SpaceError('invalid_ext_argument', 'A command for git cannot carry an empty argument or a control character');
78
+ }
79
+ return text.replaceAll('%', '%%').replaceAll(' ', '% ');
80
+ });
81
+ return `ext::${escaped.join(' ')}`;
82
+ }
83
+
84
+ const removeWithRetries = (directory) => fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
85
+
86
+ /**
87
+ * The host side of one direction. `git` is a host git from `createHostGit`, `temporaryDirectory` where
88
+ * a call's temporary folder lives, `removeDirectory` how it goes, injectable so a test can make it
89
+ * fail. `name` is how messages call the direction, `code in` or `code out`.
90
+ */
91
+ export function createTransferSession({ git, temporaryDirectory, removeDirectory = removeWithRetries, name }) {
92
+ const Name = `${name[0].toUpperCase()}${name.slice(1)}`;
93
+
94
+ /**
95
+ * Runs `work(g, directory)` with a temporary directory and a host git `g` that starts nothing of
96
+ * the user's. `core.hooksPath` points at an empty folder of ours: without it the user's
97
+ * `post-index-change` hook ran three times per snapshot, with `GIT_INDEX_FILE` pointing at our
98
+ * copy, and their `reference-transaction` hook ran on every write of our ref. `core.fsmonitor` is
99
+ * empty: a user's fsmonitor program ran ten times per snapshot, and `core.fsmonitor=true` left a
100
+ * `git fsmonitor--daemon` running on the host with its files in the user's `.git`. Empty, and not
101
+ * `false`, because before git 2.36 the value is a command path, where `false` names a program.
102
+ *
103
+ * A failure that is not a SpaceError becomes one with `failureCode`. Removing the folder is retried,
104
+ * and it never replaces the error of the work: when it still fails, the error says which folder was
105
+ * left in `details.temporaryDirectoryLeft`. After a successful call a leftover folder is let go.
106
+ */
107
+ const withHostGit = async (failureCode, work) => {
108
+ let directory;
109
+ try {
110
+ directory = await fs.mkdtemp(path.join(temporaryDirectory, `openchamber-${name.replace(' ', '-')}-`));
111
+ } catch (error) {
112
+ throw new SpaceError(failureCode, `Could not make a temporary folder for ${name}: ${error.message}`, { step: 'make a temporary folder', cause: error.code ?? null });
113
+ }
114
+ let outcome;
115
+ let failed = false;
116
+ try {
117
+ const hooks = path.join(directory, 'no-hooks');
118
+ await fs.mkdir(hooks);
119
+ const isolated = ['-c', `core.hooksPath=${hooks.replaceAll('\\', '/')}`, '-c', 'core.fsmonitor='];
120
+ const g = {
121
+ run: (where, args, options) => git.run(where, [...isolated, ...args], options),
122
+ output: (where, args, options) => git.output(where, [...isolated, ...args], options),
123
+ };
124
+ outcome = await work(g, directory);
125
+ } catch (error) {
126
+ failed = true;
127
+ outcome = error instanceof SpaceError
128
+ ? error
129
+ : new SpaceError(failureCode, `${Name} failed on the host: ${error.message}`, { step: 'host side', cause: error.code ?? null });
130
+ }
131
+ const left = await removeDirectory(directory).then(() => false, () => true);
132
+ if (failed) {
133
+ if (left) outcome.details = { ...outcome.details, temporaryDirectoryLeft: directory };
134
+ throw outcome;
135
+ }
136
+ return outcome;
137
+ };
138
+
139
+ /** Refuses a host git older than the floor, with the version it needs. */
140
+ const requireGitVersion = async (g, directory) => {
141
+ const unreadable = (cause) => new SpaceError('git_version_unreadable', `Could not tell which git this computer has. ${Name} needs git to be installed and working.`, { cause });
142
+ let result;
143
+ try {
144
+ result = await g.run(directory, ['version']);
145
+ } catch (error) {
146
+ // A git that cannot be started at all, missing among them.
147
+ throw unreadable(error.code ?? null);
148
+ }
149
+ const version = result.code === 0 ? parseGitVersion(result.stdout) : null;
150
+ if (!version) {
151
+ throw unreadable(null);
152
+ }
153
+ if (!isGitVersionAtLeast(version, GIT_VERSION_FLOOR)) {
154
+ throw new SpaceError('git_too_old', `${Name} needs git ${GIT_VERSION_FLOOR_TEXT} or newer, and this computer has git ${version.major}.${version.minor}.${version.patch}. Update git, then try again.`, { version });
155
+ }
156
+ };
157
+
158
+ /** The top level of the work tree that `repository` is in, as a native path, or a refusal. */
159
+ const requireWorkTree = async (g, repository) => {
160
+ const found = await fs.stat(repository).then((stats) => stats.isDirectory(), () => false);
161
+ if (!found) {
162
+ throw new SpaceError('project_folder_missing', `${repository} does not exist, or is not a folder.`);
163
+ }
164
+ const inside = await g.run(repository, ['rev-parse', '--is-inside-work-tree']);
165
+ if (inside.code !== 0 || line(inside.stdout) !== 'true') {
166
+ throw new SpaceError('not_a_git_work_tree', `${repository} is not inside a git working tree. Code can travel between a space and this computer only for a git repository.`);
167
+ }
168
+ // git answers with forward slashes on Windows too.
169
+ return path.resolve(line(await g.output(repository, ['rev-parse', '--show-toplevel'])));
170
+ };
171
+
172
+ return { withHostGit, requireGitVersion, requireWorkTree };
173
+ }
@@ -0,0 +1,181 @@
1
+ // `openCommandStream` of run-command.js, and the bridge of `connect` from layout.js, run on the
2
+ // host with this Node: the stream that the dispatcher's agent uses as a socket.
3
+
4
+ import crypto from 'node:crypto';
5
+ import http from 'node:http';
6
+ import net from 'node:net';
7
+
8
+ import { afterEach, describe, expect, it } from 'vitest';
9
+
10
+ import { CONNECT_BRIDGE_PROGRAM } from './layout.js';
11
+ import { openCommandStream } from './run-command.js';
12
+
13
+ const node = process.execPath;
14
+ const ECHO = 'process.stdin.pipe(process.stdout)';
15
+ const servers = [];
16
+ const streams = [];
17
+
18
+ const listen = (server) => new Promise((resolve) => {
19
+ server.listen(0, '127.0.0.1', () => { servers.push(server); resolve(server.address().port); });
20
+ });
21
+ const open = (file, args) => {
22
+ const stream = openCommandStream(file, args);
23
+ streams.push(stream);
24
+ return stream;
25
+ };
26
+ const bridgeTo = (port) => open(node, ['-e', CONNECT_BRIDGE_PROGRAM, '127.0.0.1', String(port)]);
27
+ const closed = (stream) => new Promise((resolve) => { if (stream.closed) resolve(); else stream.once('close', resolve); });
28
+ const failure = (stream) => new Promise((resolve) => stream.once('error', resolve));
29
+ const collect = (stream) => new Promise((resolve) => {
30
+ const chunks = [];
31
+ stream.on('data', (chunk) => chunks.push(chunk));
32
+ stream.on('end', () => resolve(Buffer.concat(chunks)));
33
+ });
34
+ const alive = (pid) => {
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+
43
+ afterEach(async () => {
44
+ for (const stream of streams.splice(0)) {
45
+ stream.destroy();
46
+ await closed(stream);
47
+ }
48
+ for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve()));
49
+ });
50
+
51
+ describe('openCommandStream', () => {
52
+ it('carries bytes both ways and ends when the command ends', async () => {
53
+ const stream = open(node, ['-e', ECHO]);
54
+ const sent = crypto.randomBytes(1024 * 1024);
55
+ const received = collect(stream);
56
+ stream.end(sent);
57
+ expect((await received).equals(sent)).toBe(true);
58
+ await closed(stream);
59
+ expect(stream.errored).toBeNull();
60
+ });
61
+
62
+ it('ends with an error that carries the command\'s stderr when it fails before it answers', async () => {
63
+ const stream = open(node, ['-e', 'process.stderr.write("no route to the server"); process.exit(3)']);
64
+ stream.resume();
65
+ const error = await failure(stream);
66
+ expect(error).toMatchObject({ code: 'command_stream_failed', message: expect.stringMatching(/exited with code 3: no route to the server/) });
67
+ });
68
+
69
+ it('ends with an error when the command answers nothing and leaves quietly', async () => {
70
+ const stream = open(node, ['-e', 'process.exit(0)']);
71
+ stream.resume();
72
+ expect(await failure(stream)).toMatchObject({ code: 'command_stream_failed' });
73
+ });
74
+
75
+ it('kills the command when it is destroyed', async () => {
76
+ const stream = open(node, ['-e', 'setInterval(() => {}, 1000)']);
77
+ const { pid } = stream;
78
+ stream.destroy();
79
+ await closed(stream);
80
+ for (let attempt = 0; attempt < 50 && alive(pid); attempt += 1) await new Promise((resolve) => { setTimeout(resolve, 20); });
81
+ expect(alive(pid)).toBe(false);
82
+ });
83
+
84
+ it('emits timeout after the given silence, and not while bytes flow', async () => {
85
+ const stream = open(node, ['-e', ECHO]);
86
+ stream.resume();
87
+ let timeouts = 0;
88
+ stream.setTimeout(150, () => { timeouts += 1; });
89
+ for (let round = 0; round < 4; round += 1) {
90
+ stream.write('tick');
91
+ await new Promise((resolve) => { setTimeout(resolve, 60); });
92
+ }
93
+ expect(timeouts).toBe(0);
94
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
95
+ expect(timeouts).toBe(1);
96
+ expect(stream.timeout).toBe(150);
97
+ });
98
+
99
+ it('answers a command that cannot start with an error, not an exception', async () => {
100
+ const stream = open('/nonexistent/program/of-ours', []);
101
+ stream.resume();
102
+ expect(await failure(stream)).toMatchObject({ code: 'command_spawn_failed' });
103
+ });
104
+ });
105
+
106
+ describe('the connect bridge', () => {
107
+ it('carries HTTP requests over a keep-alive agent, many on one command', async () => {
108
+ let requests = 0;
109
+ const port = await listen(http.createServer((request, response) => {
110
+ requests += 1;
111
+ let bytes = 0;
112
+ request.on('data', (chunk) => { bytes += chunk.length; });
113
+ request.on('end', () => response.end(JSON.stringify({ url: request.url, bytes, big: 'y'.repeat(300_000) })));
114
+ }));
115
+ let opened = 0;
116
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
117
+ agent.createConnection = (_options, callback) => { opened += 1; callback(null, bridgeTo(port)); };
118
+ const call = (path, body = null) => new Promise((resolve, reject) => {
119
+ const request = http.request({ agent, host: '127.0.0.1', port: 9, path, method: body ? 'POST' : 'GET' }, (response) => {
120
+ const chunks = [];
121
+ response.on('data', (chunk) => chunks.push(chunk));
122
+ response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
123
+ });
124
+ request.on('error', reject);
125
+ request.end(body);
126
+ });
127
+
128
+ expect(await call('/one')).toMatchObject({ url: '/one', bytes: 0 });
129
+ expect(await call('/two', Buffer.alloc(2 * 1024 * 1024, 1))).toMatchObject({ url: '/two', bytes: 2 * 1024 * 1024 });
130
+ const parallel = await Promise.all(['/a', '/b', '/c'].map((path) => call(path)));
131
+ expect(parallel.map((answer) => answer.url)).toEqual(['/a', '/b', '/c']);
132
+ expect(requests).toBe(5);
133
+ expect(opened).toBe(1);
134
+ agent.destroy();
135
+ });
136
+
137
+ it('carries a large answer whole while the other side is still sending', async () => {
138
+ const outgoing = crypto.randomBytes(4 * 1024 * 1024);
139
+ const incoming = crypto.randomBytes(4 * 1024 * 1024);
140
+ let seenAtServer = null;
141
+ const port = await listen(net.createServer((socket) => {
142
+ const chunks = [];
143
+ socket.on('data', (chunk) => chunks.push(chunk));
144
+ socket.on('end', () => { seenAtServer = Buffer.concat(chunks); socket.end(); });
145
+ socket.write(incoming);
146
+ }));
147
+ const stream = bridgeTo(port);
148
+ const received = collect(stream);
149
+ stream.end(outgoing);
150
+ const answer = await received;
151
+ expect(crypto.createHash('sha256').update(answer).digest('hex')).toBe(crypto.createHash('sha256').update(incoming).digest('hex'));
152
+ await closed(stream);
153
+ expect(crypto.createHash('sha256').update(seenAtServer).digest('hex')).toBe(crypto.createHash('sha256').update(outgoing).digest('hex'));
154
+ });
155
+
156
+ it('fails with the connection error when nothing listens on the port', async () => {
157
+ const stream = bridgeTo(1);
158
+ stream.resume();
159
+ stream.write('GET / HTTP/1.1\r\n\r\n');
160
+ const error = await failure(stream);
161
+ expect(error).toMatchObject({ code: 'command_stream_failed', message: expect.stringMatching(/exited with code 1: ECONNREFUSED/) });
162
+ });
163
+
164
+ it('ends its command when the host lets go, and when the server closes', async () => {
165
+ const sockets = [];
166
+ const port = await listen(net.createServer((socket) => { sockets.push(socket); socket.write('hello'); }));
167
+ const first = bridgeTo(port);
168
+ await new Promise((resolve) => first.once('data', resolve));
169
+ const firstPid = first.pid;
170
+ first.destroy();
171
+ await closed(first);
172
+ for (let attempt = 0; attempt < 50 && alive(firstPid); attempt += 1) await new Promise((resolve) => { setTimeout(resolve, 20); });
173
+ expect(alive(firstPid)).toBe(false);
174
+
175
+ const second = bridgeTo(port);
176
+ await new Promise((resolve) => second.once('data', resolve));
177
+ sockets[1].end();
178
+ await closed(second);
179
+ expect(second.errored).toBeNull();
180
+ });
181
+ });