@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
@@ -1,5 +1,6 @@
1
- // Test support, never imported by product code. The bait repository and the host state that code
2
- // in must leave byte-identical, shared by code-in.test.js and places/code-in.docker.live.test.js.
1
+ // Test support, never imported by product code. The bait repository, the host state that code in
2
+ // and code out must leave byte-identical, and a stand-in for a space in a local folder, shared by
3
+ // code-in.test.js, code-out.test.js and the live files under places/.
3
4
 
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import crypto from 'node:crypto';
@@ -7,8 +8,13 @@ import fs from 'node:fs';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
 
11
+ import { expect } from 'vitest';
12
+
10
13
  import { createCodeIn } from './code-in.js';
14
+ import { createCodeOut } from './code-out.js';
15
+ import { SpaceError } from './errors.js';
11
16
  import { createHostGit, hostGitEnvironment } from './host-git.js';
17
+ import { IMAGE_ONLY_PATH, IMAGE_SH, IMAGE_TIMEOUT } from './layout.js';
12
18
  import { runCommand } from './run-command.js';
13
19
 
14
20
  const WIN = process.platform === 'win32';
@@ -64,7 +70,11 @@ export function createTestHost({ config = '', identity = true, ownHome = true }
64
70
  };
65
71
  // Config that would break the setup itself goes in afterwards.
66
72
  const addConfig = (text) => fs.appendFileSync(globalConfig, `\n${text}\n`);
67
- return { root, environment, git, sh, addConfig, codeIn: (place = null, options = {}) => createCodeIn({ git, place, temporaryDirectory: root, ...options }) };
73
+ return {
74
+ root, environment, git, sh, addConfig,
75
+ codeIn: (place = null, options = {}) => createCodeIn({ git, place, temporaryDirectory: root, ...options }),
76
+ codeOut: (place = null, options = {}) => createCodeOut({ git, place, temporaryDirectory: root, ...options }),
77
+ };
68
78
  }
69
79
 
70
80
  /** The bait repository: every kind of change and every kind of file that must or must not travel. */
@@ -162,7 +172,16 @@ export const hostState = (directory) => {
162
172
  // a nested repository's `.git`, is a difference like any other;
163
173
  // 2. the start ref of the spaces named, `.git/refs/openchamber/spaces/<id>/start`, with the three
164
174
  // folders git makes for it, added or removed. A start ref that moved to another commit is a difference.
175
+ // With `codeOut`, also what code out writes by design:
176
+ // 3. new packs, which a fetch writes when it brings many objects, as additions only;
177
+ // 4. the result ref of the spaces named, `.git/refs/openchamber/spaces/<id>/result`, the ref of what
178
+ // the last apply as uncommitted changes wrote, `.../applied`, the intent written while an apply
179
+ // runs, `.../applying`, what the patch of each was built from, `.../applied-from` and
180
+ // `.../applying-from`, the user's HEAD at each, `.../applied-head` and `.../applying-head`, and the
181
+ // one that says this space is applied as a branch from now on,
182
+ // `.../changes-closed`, added, removed or moved, because a later call moves each of them.
165
183
  const LOOSE_OBJECT = /^\.git\/objects\/[0-9a-f]{2}(?:\/(?:[0-9a-f]{38}|[0-9a-f]{62}))?$/;
184
+ const NEW_PACK = /^\.git\/objects\/pack\/pack-(?:[0-9a-f]{40}|[0-9a-f]{64})\.(?:pack|idx|rev)$/;
166
185
  const spaceRefPaths = (spaceId) => [
167
186
  '.git/refs/openchamber',
168
187
  '.git/refs/openchamber/spaces',
@@ -170,16 +189,136 @@ const spaceRefPaths = (spaceId) => [
170
189
  `.git/refs/openchamber/spaces/${spaceId}/start`,
171
190
  ];
172
191
 
173
- /** The paths that differ between two `hostState`s, apart from what code in writes by design for `spaceIds`. */
174
- export const unexpectedChanges = (before, after, { spaceIds = [] } = {}) => {
192
+ /**
193
+ * The paths that differ between two `hostState`s, apart from what code in writes by design for
194
+ * `spaceIds`, and with `codeOut: true` also what code out writes by design for them.
195
+ */
196
+ export const unexpectedChanges = (before, after, { spaceIds = [], codeOut = false } = {}) => {
175
197
  const ownRefs = new Set(spaceIds.flatMap(spaceRefPaths));
198
+ const resultRefs = new Set(codeOut
199
+ ? spaceIds.flatMap((spaceId) => ['result', 'applied', 'applied-from', 'applied-head', 'applying', 'applying-from', 'applying-head', 'changes-closed'].map((name) => `.git/refs/openchamber/spaces/${spaceId}/${name}`))
200
+ : []);
176
201
  const names = new Set([...Object.keys(before), ...Object.keys(after)]);
177
202
  return [...names].filter((name) => {
178
203
  if (before[name] === after[name]) return false;
204
+ if (resultRefs.has(name)) return false;
179
205
  const addedOrRemoved = before[name] === undefined || after[name] === undefined;
180
206
  if (ownRefs.has(name) && addedOrRemoved) return false;
181
- return !(before[name] === undefined && LOOSE_OBJECT.test(name));
207
+ const added = before[name] === undefined;
208
+ if (added && codeOut && NEW_PACK.test(name)) return false;
209
+ return !(added && LOOSE_OBJECT.test(name));
182
210
  }).sort();
183
211
  };
184
212
 
185
213
  export const shortStatus = (g) => g(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']);
214
+
215
+ // The other end of a push or fetch, standing in for `docker exec ... git receive-pack` or
216
+ // `git upload-pack` in a real space. It checks that the host wrote out the fixed command, and maps
217
+ // /spaces/ into a local folder. `slow` sends what upload-pack sends at about 3 MB/s, and every run
218
+ // records how its git ended in `<service>.exit`, which a killed run never writes.
219
+ const RECEIVER = `
220
+ const { spawn } = require('node:child_process');
221
+ const fs = require('node:fs');
222
+ const path = require('node:path');
223
+ const [root, behaviour, services, ...command] = process.argv.slice(2);
224
+ const [timeoutPath, dashS, signal, seconds, gitPath, service, target] = command;
225
+ if (command.length !== 7 || timeoutPath !== '/usr/bin/timeout' || dashS !== '-s' || signal !== 'KILL' || !/^[0-9]+$/.test(seconds)
226
+ || gitPath !== '/usr/bin/git' || !services.split(',').includes(service) || !target.startsWith('/spaces/')) {
227
+ process.stderr.write('unexpected command ' + JSON.stringify(command) + '\\n');
228
+ process.exit(2);
229
+ }
230
+ fs.writeFileSync(path.join(root, 'last-command.json'), JSON.stringify(command));
231
+ fs.appendFileSync(path.join(root, 'commands.log'), target + '\\n');
232
+ if (behaviour === 'refuse') { process.stderr.write('refused by the space\\n'); process.exit(1); }
233
+ if (behaviour === 'flood') {
234
+ const chunk = 'x'.repeat(65536);
235
+ const pump = () => { while (process.stderr.write(chunk)) {} process.stderr.once('drain', pump); };
236
+ pump();
237
+ } else if (behaviour === 'hang') {
238
+ const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
239
+ fs.writeFileSync(path.join(root, 'hang.pid'), String(grandchild.pid));
240
+ setInterval(() => {}, 1000);
241
+ } else {
242
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
243
+ const slow = behaviour === 'slow';
244
+ const child = spawn('git', [service, path.join(root, target)], {
245
+ stdio: ['inherit', slow ? 'pipe' : 'inherit', 'inherit'],
246
+ env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: ${JSON.stringify(os.devNull)} },
247
+ });
248
+ if (slow) {
249
+ child.stdout.on('data', (chunk) => {
250
+ child.stdout.pause();
251
+ process.stdout.write(chunk);
252
+ setTimeout(() => child.stdout.resume(), Math.ceil(chunk.length / 3000));
253
+ });
254
+ }
255
+ // The receiver ends by itself once git has closed and everything is written.
256
+ child.on('close', (code) => {
257
+ fs.writeFileSync(path.join(root, service + '.exit'), String(code));
258
+ process.exitCode = code ?? 1;
259
+ });
260
+ }
261
+ `;
262
+
263
+ /**
264
+ * A stand-in place for one space in a local folder. `exec` runs the module's fixed scripts with the
265
+ * host's `sh` and `git`, with /spaces/ mapped into the folder and a `timeout` that only drops its
266
+ * limit, since the host may have none; a script may come under the image's `timeout` itself.
267
+ * `execArgv` points at RECEIVER, kept in a folder whose name has a space and a percent sign, so the
268
+ * ext escaping is exercised through git itself. `setBehaviour` changes what the next transfer meets.
269
+ * POSIX only: the scripts run with `/bin/sh`.
270
+ *
271
+ * Without `codeOut` it stands in for code in alone, and is as strict as code in: only `receive-pack`,
272
+ * and no script wrapped in the image's `timeout`. With it, `upload-pack` and a wrapped script are
273
+ * allowed too, and the wrapper's seconds are checked.
274
+ */
275
+ export function createLocalPlace(host, behaviour = 'receive', spaceId = 'a1b2c3d4e5f6', { codeOut = false } = {}) {
276
+ const root = path.join(host.root, 'stand in 50% place');
277
+ const bin = path.join(root, 'bin');
278
+ fs.mkdirSync(path.join(root, 'spaces', spaceId), { recursive: true });
279
+ fs.mkdirSync(bin);
280
+ const receiver = path.join(root, 'receiver.cjs');
281
+ fs.writeFileSync(receiver, RECEIVER);
282
+ fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755 });
283
+ const emptyConfig = path.join(root, 'inside-gitconfig');
284
+ fs.writeFileSync(emptyConfig, '');
285
+ const insideEnvironment = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig };
286
+ const local = (inside) => path.join(root, inside);
287
+ let current = behaviour;
288
+ return {
289
+ root,
290
+ local,
291
+ setBehaviour: (next) => { current = next; },
292
+ inside: (args, { env = {}, input } = {}) => {
293
+ const result = spawnSync('git', args, { env: { ...insideEnvironment, ...env }, input, encoding: 'utf8' });
294
+ return { code: result.status, stdout: result.stdout, stderr: result.stderr };
295
+ },
296
+ lastCommand: () => JSON.parse(fs.readFileSync(path.join(root, 'last-command.json'), 'utf8')),
297
+ /** How many pushes reached the history side repository. */
298
+ historyPushes: () => (fs.existsSync(path.join(root, 'commands.log')) ? fs.readFileSync(path.join(root, 'commands.log'), 'utf8').split('\n').filter((line) => line.endsWith('.openchamber-history.git')).length : 0),
299
+ hangPid: () => Number(fs.readFileSync(path.join(root, 'hang.pid'), 'utf8')),
300
+ /** How the last upload-pack ended, or null when it was killed before it could say. */
301
+ uploadPackExit: () => (fs.existsSync(path.join(root, 'upload-pack.exit')) ? fs.readFileSync(path.join(root, 'upload-pack.exit'), 'utf8') : null),
302
+ place: {
303
+ execArgv: async (id) => {
304
+ if (id !== spaceId) throw new SpaceError('space_not_found', 'no such space');
305
+ return [process.execPath, receiver, path.join(root), current, codeOut ? 'receive-pack,upload-pack' : 'receive-pack'];
306
+ },
307
+ exec: async (id, argv, { timeoutMs }) => {
308
+ const wrapped = argv[0] === IMAGE_TIMEOUT;
309
+ const unlimited = wrapped ? argv.slice(4) : argv;
310
+ if (wrapped) {
311
+ expect(codeOut, 'code in runs no script under the image timeout').toBe(true);
312
+ expect(argv.slice(1, 3)).toEqual(['-s', 'KILL']);
313
+ expect(Number(argv[3]), 'the seconds of the inner limit').toBeGreaterThan(0);
314
+ }
315
+ const [shell, flag, script, zero, ...args] = unlimited;
316
+ expect([shell, flag, zero]).toEqual([IMAGE_SH, '-c', 'sh']);
317
+ expect(script.startsWith(IMAGE_ONLY_PATH)).toBe(true);
318
+ // Quoted: the stand-in's folder name has a space in it, which the image's own PATH never has.
319
+ const localScript = `PATH='${insideEnvironment.PATH}';${script.slice(IMAGE_ONLY_PATH.length)}`;
320
+ return runCommand('/bin/sh', ['-c', localScript, zero, ...args.map((arg) => (arg.startsWith('/spaces/') ? local(arg) : arg))], { env: insideEnvironment, timeoutMs });
321
+ },
322
+ },
323
+ };
324
+ }
@@ -10,9 +10,12 @@ import fs from 'node:fs/promises';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
 
13
+ import {
14
+ EXT_UNCARRIABLE, INNER_MARGIN_SECONDS, SNAPSHOT_IDENTITY, buildExtUrl, createTransferSession, innerSeconds, line,
15
+ objectIdPattern, requireInnerMargin, requireTimeout, spaceRefPrefix, startRef, zeroObjectId,
16
+ } from './code-transfer.js';
13
17
  import { SpaceError } from './errors.js';
14
18
  import { tail } from './exec-http.js';
15
- import { isGitVersionAtLeast, parseGitVersion } from './host-git.js';
16
19
  import { requireSpaceId } from './labels.js';
17
20
  import { IMAGE_GIT, IMAGE_ONLY_PATH, IMAGE_SH, IMAGE_TIMEOUT, requireSpaceProjectPath, spaceHistoryPath, spaceProjectPath } from './layout.js';
18
21
 
@@ -23,28 +26,9 @@ const HISTORY_TIMEOUT_MS = 30 * 60_000;
23
26
  // A push prints nothing with `--quiet`. What it does print comes from the receiving side, which
24
27
  // is the space. More than this, and the transfer is killed like a hanging one.
25
28
  const PUSH_MAX_OUTPUT_BYTES = 1024 * 1024;
26
- // The limit inside is the host's limit plus this margin, so it never ends a push the host still
27
- // waits for, and a push the host killed leaves nothing running inside once it passes.
28
- const INNER_MARGIN_SECONDS = 5;
29
- const MAX_INNER_MARGIN_SECONDS = 3600;
30
29
  const INSIDE_TIMEOUT_MS = 5 * 60_000;
31
- // The oldest host git code in works with. `git init --object-format` first appears in the git-init
32
- // manual of 2.27.0; `rev-parse --show-object-format` and `add --pathspec-from-file` in 2.25.0; every
33
- // other option used here is older. See DOCUMENTATION.md, "Host git".
34
- const GIT_VERSION_FLOOR = Object.freeze({ major: 2, minor: 27, patch: 0 });
35
- const GIT_VERSION_FLOOR_TEXT = '2.27';
36
-
37
- // The snapshot commits carry a fixed identity, so a user without `user.name` can still create a space.
38
- const SNAPSHOT_IDENTITY = Object.freeze({
39
- GIT_AUTHOR_NAME: 'OpenChamber',
40
- GIT_AUTHOR_EMAIL: 'spaces@openchamber.invalid',
41
- GIT_COMMITTER_NAME: 'OpenChamber',
42
- GIT_COMMITTER_EMAIL: 'spaces@openchamber.invalid',
43
- });
44
30
 
45
31
  const MODES = new Set(['uncommitted', 'clean']);
46
- // A git `ext::` URL splits on spaces itself and cannot carry these at all.
47
- const EXT_UNCARRIABLE = /[\x00-\x1f\x7f]/;
48
32
  // A value that is copied into the space's git config: one line, and short.
49
33
  const COPYABLE_VALUE = /^[^\x00-\x1f\x7f]{1,256}$/;
50
34
 
@@ -53,9 +37,6 @@ const INSIDE_BASE = 'refs/openchamber/base';
53
37
  const INSIDE_START_INDEX = 'refs/openchamber/start-index';
54
38
  const INSIDE_START = 'refs/openchamber/start';
55
39
 
56
- const spaceRefPrefix = (spaceId) => `refs/openchamber/spaces/${requireSpaceId(spaceId)}/`;
57
- const startRef = (spaceId) => `${spaceRefPrefix(spaceId)}start`;
58
-
59
40
  // The fixed scripts that run inside the space. Every value is a positional argument.
60
41
  // Exit code 3 of the init script means the project path is already there.
61
42
  const PATH_IN_USE_EXIT_CODE = 3;
@@ -91,52 +72,6 @@ const UNSHALLOW_SCRIPT = [
91
72
  ].join(' ');
92
73
  const REMOVE_SIDE_SCRIPT = `${IMAGE_ONLY_PATH} rm -rf "$1"`;
93
74
 
94
- // One line of git output, without its line end.
95
- const line = (text) => text.replace(/\r?\n$/, '');
96
- const zeroObjectId = (objectFormat) => '0'.repeat(objectFormat === 'sha256' ? 64 : 40);
97
- const objectIdPattern = (objectFormat) => (objectFormat === 'sha256' ? /^[0-9a-f]{64}$/ : /^[0-9a-f]{40}$/);
98
- const innerSeconds = (timeoutMs, marginSeconds) => Math.ceil(timeoutMs / 1000) + marginSeconds;
99
-
100
- const MIN_TIMEOUT_MS = 1_000;
101
- const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
102
-
103
- /** The host's limit on a push, in whole milliseconds from a second to a day. */
104
- const requireTimeout = (value) => {
105
- if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
106
- 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}`);
107
- }
108
- return value;
109
- };
110
-
111
- /** The margin between the host's limit and the one inside, in whole seconds: a length, and nothing else. */
112
- const requireInnerMargin = (value) => {
113
- if (!Number.isInteger(value) || value < 1 || value > MAX_INNER_MARGIN_SECONDS) {
114
- 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}`);
115
- }
116
- return value;
117
- };
118
-
119
- /**
120
- * A git `ext::` URL for an argv. git-remote-ext splits the text after `ext::` on spaces itself, so a
121
- * space inside an argument is written `% ` and a percent `%%`, as its documentation says. The docker
122
- * CLI on Windows can live under a path with spaces. An empty argument or one with a control
123
- * character cannot be carried, and is refused. The URL goes to git as one argv element, never
124
- * through a shell.
125
- */
126
- export function buildExtUrl(argv) {
127
- if (!Array.isArray(argv) || argv.length === 0) {
128
- throw new SpaceError('invalid_ext_argument', 'A command for git is a non-empty list of arguments');
129
- }
130
- const escaped = argv.map((argument) => {
131
- const text = String(argument);
132
- if (text === '' || EXT_UNCARRIABLE.test(text)) {
133
- throw new SpaceError('invalid_ext_argument', 'A command for git cannot carry an empty argument or a control character');
134
- }
135
- return text.replaceAll('%', '%%').replaceAll(' ', '% ');
136
- });
137
- return `ext::${escaped.join(' ')}`;
138
- }
139
-
140
75
  /** The receiving side of a push into the space: fixed programs by absolute path, under the space's own time limit. */
141
76
  const receivePackUrl = (execArgv, seconds, target) => buildExtUrl([
142
77
  ...execArgv, IMAGE_TIMEOUT, '-s', 'KILL', String(seconds), IMAGE_GIT, 'receive-pack', target,
@@ -181,86 +116,8 @@ const asHistoryFailure = (error, step) => {
181
116
  return new SpaceError('history_transfer_failed', ours ? error.message : `The history failed on the host: ${error.message}`, details);
182
117
  };
183
118
 
184
- const removeWithRetries = (directory) => fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
185
-
186
- export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), removeDirectory = removeWithRetries }) {
187
- /**
188
- * Runs `work(g, directory)` with a temporary directory and a host git `g` that starts nothing of
189
- * the user's. `core.hooksPath` points at an empty folder of ours: without it the user's
190
- * `post-index-change` hook ran three times per snapshot, with `GIT_INDEX_FILE` pointing at our
191
- * copy, and their `reference-transaction` hook ran on every write of our ref. `core.fsmonitor` is
192
- * empty: a user's fsmonitor program ran ten times per snapshot, and `core.fsmonitor=true` left a
193
- * `git fsmonitor--daemon` running on the host with its files in the user's `.git`. Empty, and not
194
- * `false`, because before git 2.36 the value is a command path, where `false` names a program.
195
- *
196
- * A failure that is not a SpaceError becomes one with `failureCode`. Removing the folder is retried,
197
- * and it never replaces the error of the work: when it still fails, the error says which folder was
198
- * left in `details.temporaryDirectoryLeft`. After a successful call a leftover folder is let go.
199
- */
200
- const withHostGit = async (failureCode, work) => {
201
- let directory;
202
- try {
203
- directory = await fs.mkdtemp(path.join(temporaryDirectory, 'openchamber-code-in-'));
204
- } catch (error) {
205
- throw new SpaceError(failureCode, `Could not make a temporary folder for code in: ${error.message}`, { step: 'make a temporary folder', cause: error.code ?? null });
206
- }
207
- let outcome;
208
- let failed = false;
209
- try {
210
- const hooks = path.join(directory, 'no-hooks');
211
- await fs.mkdir(hooks);
212
- const isolated = ['-c', `core.hooksPath=${hooks.replaceAll('\\', '/')}`, '-c', 'core.fsmonitor='];
213
- const g = {
214
- run: (where, args, options) => git.run(where, [...isolated, ...args], options),
215
- output: (where, args, options) => git.output(where, [...isolated, ...args], options),
216
- };
217
- outcome = await work(g, directory);
218
- } catch (error) {
219
- failed = true;
220
- outcome = error instanceof SpaceError
221
- ? error
222
- : new SpaceError(failureCode, `Code in failed on the host: ${error.message}`, { step: 'host side', cause: error.code ?? null });
223
- }
224
- const left = await removeDirectory(directory).then(() => false, () => true);
225
- if (failed) {
226
- if (left) outcome.details = { ...outcome.details, temporaryDirectoryLeft: directory };
227
- throw outcome;
228
- }
229
- return outcome;
230
- };
231
-
232
- /** Refuses a host git older than the floor, with the version it needs. */
233
- const requireGitVersion = async (g, directory) => {
234
- const unreadable = (cause) => new SpaceError('git_version_unreadable', 'Could not tell which git this computer has. Code in needs git to be installed and working.', { cause });
235
- let result;
236
- try {
237
- result = await g.run(directory, ['version']);
238
- } catch (error) {
239
- // A git that cannot be started at all, missing among them.
240
- throw unreadable(error.code ?? null);
241
- }
242
- const version = result.code === 0 ? parseGitVersion(result.stdout) : null;
243
- if (!version) {
244
- throw unreadable(null);
245
- }
246
- if (!isGitVersionAtLeast(version, GIT_VERSION_FLOOR)) {
247
- throw new SpaceError('git_too_old', `Code in needs git ${GIT_VERSION_FLOOR_TEXT} or newer, and this computer has git ${version.major}.${version.minor}.${version.patch}. Update git, then create the space.`, { version });
248
- }
249
- };
250
-
251
- /** The top level of the work tree that `repository` is in, as a native path, or a refusal. */
252
- const requireWorkTree = async (g, repository) => {
253
- const found = await fs.stat(repository).then((stats) => stats.isDirectory(), () => false);
254
- if (!found) {
255
- throw new SpaceError('project_folder_missing', `${repository} does not exist, or is not a folder.`);
256
- }
257
- const inside = await g.run(repository, ['rev-parse', '--is-inside-work-tree']);
258
- if (inside.code !== 0 || line(inside.stdout) !== 'true') {
259
- throw new SpaceError('not_a_git_work_tree', `${repository} is not inside a git working tree. Code can travel into a space only from a git repository.`);
260
- }
261
- // git answers with forward slashes on Windows too.
262
- return path.resolve(line(await g.output(repository, ['rev-parse', '--show-toplevel'])));
263
- };
119
+ export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), removeDirectory }) {
120
+ const { withHostGit, requireGitVersion, requireWorkTree } = createTransferSession({ git, temporaryDirectory, removeDirectory, name: 'code in' });
264
121
 
265
122
  /** What a transfer needs to know about the repository, after the refusals that come first. */
266
123
  const inspectRepository = async (g, repository) => {
@@ -425,7 +282,10 @@ export function createCodeIn({ git, place, temporaryDirectory = os.tmpdir(), rem
425
282
  */
426
283
  const takeSnapshot = (request) => withHostGit('code_in_failed', (g, directory) => snapshot(g, directory, request ?? {}));
427
284
 
428
- /** Deletes every host ref of this space under `refs/openchamber/spaces/<id>/`, in one transaction. Resolves the deleted names. */
285
+ /**
286
+ * Deletes every host ref of this space under `refs/openchamber/spaces/<id>/`, in one transaction: the
287
+ * start of code in and the result of code out. Resolves the deleted names.
288
+ */
429
289
  const removeSpaceRefs = (request) => withHostGit('code_in_failed', async (g) => {
430
290
  const { repository, spaceId } = request ?? {};
431
291
  requireSpaceId(spaceId);
@@ -9,11 +9,11 @@ import path from 'node:path';
9
9
 
10
10
  import { afterAll, describe, expect, it } from 'vitest';
11
11
 
12
- import { SECRET, SETUP_GIT, blob, createTestHost, forConfig, hostState, makeBait, readTree, removeTestHosts, shortStatus, unexpectedChanges } from './code-in-bait.js';
13
- import { buildExtUrl, createCodeIn } from './code-in.js';
12
+ import { SECRET, SETUP_GIT, blob, createLocalPlace, createTestHost, forConfig, hostState, makeBait, readTree, removeTestHosts, shortStatus, unexpectedChanges } from './code-in-bait.js';
13
+ import { createCodeIn } from './code-in.js';
14
+ import { buildExtUrl } from './code-transfer.js';
14
15
  import { SpaceError } from './errors.js';
15
16
  import { createHostGit } from './host-git.js';
16
- import { IMAGE_ONLY_PATH, IMAGE_SH } from './layout.js';
17
17
  import { runCommand } from './run-command.js';
18
18
 
19
19
  const WIN = process.platform === 'win32';
@@ -441,6 +441,7 @@ describe('the git version floor', () => {
441
441
  });
442
442
 
443
443
  it.each([
444
+ ['git version 2.28.1\n', 'git_too_old'],
444
445
  ['git version 2.26.2\n', 'git_too_old'],
445
446
  ['git version 1.9.5\n', 'git_too_old'],
446
447
  ['something else\n', 'git_version_unreadable'],
@@ -451,14 +452,14 @@ describe('the git version floor', () => {
451
452
  const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
452
453
  const failure = await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'uncommitted' }).catch((error) => error);
453
454
  expect(failure).toMatchObject({ code });
454
- if (code === 'git_too_old') expect(failure.message).toMatch(/needs git 2\.27 or newer/);
455
+ if (code === 'git_too_old') expect(failure.message).toMatch(/needs git 2\.29 or newer/);
455
456
  expect(changedSince(before, repo, [])).toEqual([]);
456
457
  });
457
458
 
458
- it('lets 2.27.0 and every build suffix through, and refuses an old git for the history too', async () => {
459
+ it('lets 2.29.0 and every build suffix through, and refuses an old git for the history too', async () => {
459
460
  const host = createTestHost();
460
461
  const { repo } = makeBait(host);
461
- for (const text of ['git version 2.27.0\n', 'git version 2.50.1 (Apple Git-155)\n', 'git version 2.54.0.windows.1\n']) {
462
+ for (const text of ['git version 2.29.0\n', 'git version 2.50.1 (Apple Git-155)\n', 'git version 2.54.0.windows.1\n']) {
462
463
  const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
463
464
  await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'clean' });
464
465
  await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID });
@@ -605,83 +606,6 @@ describe('failures keep their codes', () => {
605
606
  });
606
607
  });
607
608
 
608
- // The receiving side of a push, standing in for `docker exec ... git receive-pack` in a real space.
609
- // It checks that the host wrote out the fixed command, and maps /spaces/ into a local folder.
610
- const RECEIVER = `
611
- const { spawn } = require('node:child_process');
612
- const fs = require('node:fs');
613
- const path = require('node:path');
614
- const [root, behaviour, ...command] = process.argv.slice(2);
615
- const [timeoutPath, dashS, signal, seconds, gitPath, service, target] = command;
616
- if (command.length !== 7 || timeoutPath !== '/usr/bin/timeout' || dashS !== '-s' || signal !== 'KILL' || !/^[0-9]+$/.test(seconds)
617
- || gitPath !== '/usr/bin/git' || service !== 'receive-pack' || !target.startsWith('/spaces/')) {
618
- process.stderr.write('unexpected command ' + JSON.stringify(command) + '\\n');
619
- process.exit(2);
620
- }
621
- fs.writeFileSync(path.join(root, 'last-command.json'), JSON.stringify(command));
622
- fs.appendFileSync(path.join(root, 'commands.log'), target + '\\n');
623
- if (behaviour === 'refuse') { process.stderr.write('refused by the space\\n'); process.exit(1); }
624
- if (behaviour === 'flood') {
625
- const chunk = 'x'.repeat(65536);
626
- const pump = () => { while (process.stderr.write(chunk)) {} process.stderr.once('drain', pump); };
627
- pump();
628
- } else if (behaviour === 'hang') {
629
- const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
630
- fs.writeFileSync(path.join(root, 'hang.pid'), String(grandchild.pid));
631
- setInterval(() => {}, 1000);
632
- } else {
633
- const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
634
- const child = spawn('git', ['receive-pack', path.join(root, target)], { stdio: 'inherit', env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: ${JSON.stringify(os.devNull)} } });
635
- child.on('exit', (code) => process.exit(code ?? 1));
636
- }
637
- `;
638
-
639
- /**
640
- * A stand-in place for one space in a local folder. `exec` runs the module's fixed scripts with the
641
- * host's `sh` and `git`, with /spaces/ mapped into the folder and a `timeout` that only drops its
642
- * limit, since the host may have none. `execArgv` points at RECEIVER, kept in a folder whose name has
643
- * a space and a percent sign, so the ext escaping is exercised through git itself.
644
- */
645
- function createLocalPlace(host, behaviour = 'receive') {
646
- const root = path.join(host.root, 'stand in 50% place');
647
- const bin = path.join(root, 'bin');
648
- fs.mkdirSync(path.join(root, 'spaces', SPACE_ID), { recursive: true });
649
- fs.mkdirSync(bin);
650
- const receiver = path.join(root, 'receiver.cjs');
651
- fs.writeFileSync(receiver, RECEIVER);
652
- fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755 });
653
- const emptyConfig = path.join(root, 'inside-gitconfig');
654
- fs.writeFileSync(emptyConfig, '');
655
- const insideEnvironment = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig };
656
- const local = (inside) => path.join(root, inside);
657
- return {
658
- root,
659
- local,
660
- inside: (args) => {
661
- const result = spawnSync('git', args, { env: insideEnvironment, encoding: 'utf8' });
662
- return { code: result.status, stdout: result.stdout };
663
- },
664
- lastCommand: () => JSON.parse(fs.readFileSync(path.join(root, 'last-command.json'), 'utf8')),
665
- /** How many pushes reached the history side repository. */
666
- historyPushes: () => (fs.existsSync(path.join(root, 'commands.log')) ? fs.readFileSync(path.join(root, 'commands.log'), 'utf8').split('\n').filter((line) => line.endsWith('.openchamber-history.git')).length : 0),
667
- hangPid: () => Number(fs.readFileSync(path.join(root, 'hang.pid'), 'utf8')),
668
- place: {
669
- execArgv: async (spaceId) => {
670
- if (spaceId !== SPACE_ID) throw new SpaceError('space_not_found', 'no such space');
671
- return [process.execPath, receiver, path.join(root), behaviour];
672
- },
673
- exec: async (spaceId, argv, { timeoutMs }) => {
674
- const [shell, flag, script, zero, ...args] = argv;
675
- expect([shell, flag, zero]).toEqual([IMAGE_SH, '-c', 'sh']);
676
- expect(script.startsWith(IMAGE_ONLY_PATH)).toBe(true);
677
- // Quoted: the stand-in's folder name has a space in it, which the image's own PATH never has.
678
- const localScript = `PATH='${insideEnvironment.PATH}';${script.slice(IMAGE_ONLY_PATH.length)}`;
679
- return runCommand('/bin/sh', ['-c', localScript, zero, ...args.map((arg) => (arg.startsWith('/spaces/') ? local(arg) : arg))], { env: insideEnvironment, timeoutMs });
680
- },
681
- },
682
- };
683
- }
684
-
685
609
  // Every hook that git on the host could run during code in, each writing a marker of its own name.
686
610
  const HOOK_NAMES = ['pre-push', 'post-index-change', 'reference-transaction', 'post-checkout', 'pre-commit', 'post-commit', 'pre-auto-gc', 'post-rewrite', 'push-to-checkout', 'pre-receive', 'post-receive', 'update', 'post-update'];
687
611