@vantran-se/openchamber-web 2.0.4 → 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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.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-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.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 +3 -3
  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
@@ -1,10 +1,12 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { getEventListeners } from 'node:events';
2
3
  import fs from 'node:fs';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
 
6
7
  import { afterEach, describe, expect, it } from 'vitest';
7
8
 
9
+ import { SpaceError } from './errors.js';
8
10
  import { killLiveTrees, killProcessTree, liveTreeCount, runCommand } from './run-command.js';
9
11
 
10
12
  const node = process.execPath;
@@ -96,6 +98,16 @@ describe('runCommand', () => {
96
98
  });
97
99
  });
98
100
 
101
+ // A failing `git apply --check` prints a line or two per path; its exit code is the answer.
102
+ it('keeps only the last bytes of each stream past the cap with keepTail, and still answers with the exit code', async () => {
103
+ const flood = 'for (let i = 0; i < 4000; i += 1) process.stderr.write(`line ${i}\\n`.padStart(100, "x")); process.stdout.write("out"); process.exitCode = 3;';
104
+ const done = await runCommand(node, ['-e', flood], { maxOutputBytes: 10_000, keepTail: true, timeoutMs: 20_000 });
105
+ expect(done.code).toBe(3);
106
+ expect(done.stdout).toBe('out');
107
+ expect(Buffer.byteLength(done.stderr)).toBe(10_000);
108
+ expect(done.stderr.endsWith(`${'line 3999\n'.padStart(100, 'x')}`)).toBe(true);
109
+ });
110
+
99
111
  it('rejects when the executable does not exist', async () => {
100
112
  await expect(runCommand('/nonexistent/openchamber-no-such-binary', ['version'])).rejects.toMatchObject({
101
113
  code: 'command_spawn_failed',
@@ -230,11 +242,11 @@ const [pidFile, mode] = process.argv.slice(2);
230
242
  if (mode === 'sigint') process.on('SIGINT', () => process.exit(130));
231
243
  // An exit listener registered before ours that throws, as the serve command registers one earlier.
232
244
  if (mode === 'throwing') process.on('exit', () => { throw new Error('an earlier exit listener'); });
233
- runCommand(process.execPath, ['-e', ${JSON.stringify(parentScript)}], { killTree: true, timeoutMs: 120000 }).catch(() => {});
245
+ runCommand(process.execPath, ['-e', ${JSON.stringify(parentScript)}], { killTree: true, keepAtExit: mode === 'keep', timeoutMs: 120000 }).catch(() => {});
234
246
  const ready = setInterval(() => {
235
247
  if (!fs.existsSync(pidFile)) return;
236
248
  clearInterval(ready);
237
- if (mode === 'exit' || mode === 'throwing') process.exit(0);
249
+ if (mode === 'exit' || mode === 'throwing' || mode === 'keep') process.exit(0);
238
250
  fs.writeFileSync(pidFile + '.ready', '');
239
251
  setInterval(() => {}, 1000);
240
252
  }, 50);
@@ -271,6 +283,77 @@ const ready = setInterval(() => {
271
283
  expect(await waitFor(() => !isAlive(child) && !isAlive(grandchild), 5000)).toBe(true);
272
284
  });
273
285
 
286
+ // `keepAtExit`, for `git apply`: killed in the middle it leaves a part written, so an exit leaves it.
287
+ // Windows has no such test: there libuv's job object ends the child with this process whatever we do.
288
+ it.skipIf(process.platform === 'win32')('leaves the tree of a keepAtExit child alive when the process leaves with process.exit', async () => {
289
+ const { child, grandchild } = await runWrapper('keep');
290
+ expect(isRealPid(child) && isRealPid(grandchild)).toBe(true);
291
+ expect(await waitFor(() => !isAlive(child) || !isAlive(grandchild), 1500)).toBe(false);
292
+ });
293
+
294
+ // The reviewer's probe: a `git apply` kept at exit whose smudge filter prints on stderr after the host
295
+ // left. Through a pipe to the dead host the print was SIGPIPE, and the apply stopped with files
296
+ // deleted; with the output in a file of its own it finishes, every file changed.
297
+ it.skipIf(process.platform === 'win32')('lets a keepAtExit git apply finish after the process left, while its filter prints', { timeout: 40_000 }, async () => {
298
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-kept-apply-'));
299
+ try {
300
+ const repo = path.join(directory, 'repo');
301
+ const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.invalid', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.invalid' };
302
+ const git = (args) => {
303
+ const done = spawnSync('git', ['-C', repo, ...args], { env, encoding: 'utf8' });
304
+ if (done.status !== 0) throw new Error(`git ${args.join(' ')}: ${done.stderr}`);
305
+ return done.stdout;
306
+ };
307
+ fs.mkdirSync(repo);
308
+ git(['init', '--quiet']);
309
+ const names = Array.from({ length: 20 }, (_, index) => `f${String(index).padStart(2, '0')}.txt`);
310
+ for (const name of names) fs.writeFileSync(path.join(repo, name), 'as it was\n');
311
+ git(['add', '.']);
312
+ git(['commit', '--quiet', '-m', 'files']);
313
+ for (const name of names) fs.writeFileSync(path.join(repo, name), 'changed\n');
314
+ const patch = path.join(directory, 'change.patch');
315
+ fs.writeFileSync(patch, git(['diff', '--binary']));
316
+ git(['checkout', '--quiet', '--', '.']);
317
+ const noisy = path.join(directory, 'noisy-smudge.sh');
318
+ fs.writeFileSync(noisy, '#!/bin/sh\nsleep 0.2\necho "a smudge filter that talks" >&2\ncat\n', { mode: 0o755 });
319
+ git(['config', 'filter.noisy.smudge', noisy]);
320
+ git(['config', 'filter.noisy.clean', 'cat']);
321
+ fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=noisy\n');
322
+ const wrapper = path.join(directory, 'host.mjs');
323
+ fs.writeFileSync(wrapper, `
324
+ import { runCommand } from ${JSON.stringify(new URL('./run-command.js', import.meta.url).href)};
325
+ runCommand('git', ['-C', ${JSON.stringify(repo)}, 'apply', '--binary', ${JSON.stringify(patch)}], { env: ${JSON.stringify(env)}, killTree: true, keepAtExit: true, keepTail: true, maxOutputBytes: 65536, timeoutMs: 60000 }).catch(() => {});
326
+ setTimeout(() => process.exit(0), 1000);
327
+ `);
328
+ const host = spawn(node, [wrapper], { stdio: 'ignore' });
329
+ await new Promise((resolve) => { host.on('exit', resolve); });
330
+ const holds = (name) => fs.existsSync(path.join(repo, name)) && fs.readFileSync(path.join(repo, name), 'utf8') === 'changed\n';
331
+ // Four seconds of smudging in all, most of it after the host left.
332
+ expect(await waitFor(() => names.every(holds), 20_000)).toBe(true);
333
+ expect(git(['status', '--porcelain']).split('\n').filter(Boolean)).toEqual(names.map((name) => ` M ${name}`));
334
+ } finally {
335
+ fs.rmSync(directory, { recursive: true, force: true });
336
+ }
337
+ });
338
+
339
+ it('reads the stderr of a keepAtExit child back, its last maxOutputBytes with keepTail, and refuses more without it', async () => {
340
+ const script = 'process.stderr.write("x".repeat(5000) + "end"); process.stdout.write("gone"); process.exitCode = 2;';
341
+ expect(await runCommand(node, ['-e', script], { killTree: true, keepAtExit: true, keepTail: true, maxOutputBytes: 10 })).toEqual({ code: 2, stdout: '', stderr: 'xxxxxxxend' });
342
+ await expect(runCommand(node, ['-e', script], { killTree: true, keepAtExit: true, maxOutputBytes: 10 })).rejects.toMatchObject({ code: 'command_output_too_large' });
343
+ expect(await runCommand(node, ['-e', script], { killTree: true, keepAtExit: true })).toMatchObject({ code: 2, stderr: `${'x'.repeat(5000)}end` });
344
+ expect(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('openchamber-stderr-'))).toEqual([]);
345
+ });
346
+
347
+ it('keeps a keepAtExit child out of the live set, and still kills its tree at the timeout', async () => {
348
+ const running = runCommand(node, ['-e', 'setInterval(() => {}, 1000)'], { killTree: true, keepAtExit: true, timeoutMs: 1500 }).catch((error) => error);
349
+ try {
350
+ expect(liveTreeCount()).toBe(0);
351
+ } finally {
352
+ // Out of the set, only its timeout ends it: waited for even when the assertion failed.
353
+ expect(await running).toMatchObject({ code: 'command_timeout' });
354
+ }
355
+ });
356
+
274
357
  it('still kills the tree when an exit listener registered before ours throws', async () => {
275
358
  const { child, grandchild } = await runWrapper('throwing');
276
359
  expect(isRealPid(child) && isRealPid(grandchild)).toBe(true);
@@ -394,3 +477,110 @@ setTimeout(() => process.exit(0), 200);
394
477
  }, 20_000);
395
478
  });
396
479
 
480
+
481
+ // The caller stops a running child from outside, as code out does when the quarantine passes its
482
+ // size cap. The rejection is the caller's own reason, whatever the child's exit looked like.
483
+ describe('runCommand with an abort signal', () => {
484
+ const leftovers = [];
485
+ afterEach(() => {
486
+ for (const pid of leftovers.splice(0)) {
487
+ if (isRealPid(pid) && isAlive(pid)) process.kill(pid, 'SIGKILL');
488
+ }
489
+ });
490
+ const abortListeners = (signal) => getEventListeners(signal, 'abort').length;
491
+ const stopReason = () => new SpaceError('stopped_by_caller', 'the caller stopped it');
492
+
493
+ it('starts nothing when the signal is already aborted, and rejects with its reason', async () => {
494
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-abort-before-'));
495
+ const marker = path.join(directory, 'started');
496
+ try {
497
+ const reason = stopReason();
498
+ const error = await runCommand(node, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, '')`], { killTree: true, signal: AbortSignal.abort(reason) }).catch((caught) => caught);
499
+ expect(error).toBe(reason);
500
+ // The control: the same command without the signal does start and leave its marker.
501
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
502
+ expect(fs.existsSync(marker)).toBe(false);
503
+ expect(liveTreeCount()).toBe(0);
504
+ await runCommand(node, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, '')`]);
505
+ expect(fs.existsSync(marker)).toBe(true);
506
+ } finally {
507
+ fs.rmSync(directory, { recursive: true, force: true });
508
+ }
509
+ });
510
+
511
+ // On Windows this runs the real `taskkill /T`, whose child exits with code 1 and no signal.
512
+ it('kills the whole tree while it runs, rejects with the reason once the child is gone, and forgets the tree', async () => {
513
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-abort-during-'));
514
+ const pidFile = path.join(directory, 'pids.json');
515
+ try {
516
+ const controller = new AbortController();
517
+ const reason = stopReason();
518
+ const started = Date.now();
519
+ const running = runCommand(node, ['-e', parentOfSleeper(pidFile)], { killTree: true, timeoutMs: 60_000, signal: controller.signal }).catch((caught) => caught);
520
+ expect(await waitFor(() => fs.existsSync(pidFile) && fs.statSync(pidFile).size > 0)).toBe(true);
521
+ const pids = JSON.parse(fs.readFileSync(pidFile, 'utf8'));
522
+ leftovers.push(pids.grandchild, pids.child);
523
+ expect(isAlive(pids.child) && isAlive(pids.grandchild)).toBe(true);
524
+ expect(abortListeners(controller.signal)).toBe(1);
525
+ controller.abort(reason);
526
+ expect(await running).toBe(reason);
527
+ expect(isAlive(pids.child)).toBe(false);
528
+ expect(await waitFor(() => !isAlive(pids.grandchild))).toBe(true);
529
+ expect(Date.now() - started).toBeLessThan(30_000);
530
+ expect(liveTreeCount()).toBe(0);
531
+ expect(abortListeners(controller.signal)).toBe(0);
532
+ } finally {
533
+ fs.rmSync(directory, { recursive: true, force: true });
534
+ }
535
+ });
536
+
537
+ it('kills the child alone without killTree, and rejects with the reason', async () => {
538
+ const controller = new AbortController();
539
+ const reason = stopReason();
540
+ const running = runCommand(node, ['-e', 'process.stdout.write("up"); setInterval(() => {}, 1000)'], { timeoutMs: 60_000, signal: controller.signal }).catch((caught) => caught);
541
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
542
+ controller.abort(reason);
543
+ expect(await running).toBe(reason);
544
+ expect(abortListeners(controller.signal)).toBe(0);
545
+ });
546
+
547
+ it('changes nothing about a run that already closed, and leaves no listener behind', async () => {
548
+ const controller = new AbortController();
549
+ expect(await runCommand(node, ['-e', 'process.stdout.write("ok")'], { killTree: true, signal: controller.signal })).toEqual({ code: 0, stdout: 'ok', stderr: '' });
550
+ expect(abortListeners(controller.signal)).toBe(0);
551
+ controller.abort(stopReason());
552
+ expect(liveTreeCount()).toBe(0);
553
+ });
554
+
555
+ // The timeout came first: its kill is the only one, and a late abort neither kills again nor
556
+ // replaces the error.
557
+ it('keeps the timeout as the error when the abort comes after it', async () => {
558
+ const controller = new AbortController();
559
+ const running = runCommand(node, ['-e', 'setInterval(() => {}, 1000)'], { killTree: true, timeoutMs: 500, signal: controller.signal }).catch((caught) => caught);
560
+ await new Promise((resolve) => { setTimeout(resolve, 700); });
561
+ expect(abortListeners(controller.signal)).toBe(0);
562
+ controller.abort(stopReason());
563
+ expect(await running).toMatchObject({ code: 'command_timeout' });
564
+ expect(liveTreeCount()).toBe(0);
565
+ });
566
+
567
+ // Whatever the caller aborts with, the rejection is a SpaceError: a string, null or nothing at all
568
+ // would otherwise reach a caller that catches SpaceErrors.
569
+ it.each([['a string', 'stopped'], ['null', null], ['nothing', undefined]])('rejects with a SpaceError when the reason is %s', async (_, reason) => {
570
+ const controller = new AbortController();
571
+ const running = runCommand(node, ['-e', 'setInterval(() => {}, 1000)'], { killTree: true, timeoutMs: 60_000, signal: controller.signal }).catch((caught) => caught);
572
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
573
+ if (reason === undefined) controller.abort(); else controller.abort(reason);
574
+ const error = await running;
575
+ expect(error).toBeInstanceOf(SpaceError);
576
+ expect(error).toMatchObject({ code: 'command_aborted' });
577
+ expect(await runCommand(node, ['-e', ''], { signal: AbortSignal.abort(reason) }).catch((caught) => caught)).toMatchObject({ code: 'command_aborted' });
578
+ });
579
+
580
+ it('leaves no listener after a spawn failure', async () => {
581
+ const controller = new AbortController();
582
+ await expect(runCommand('/nonexistent/openchamber-no-such-binary', [], { killTree: true, signal: controller.signal })).rejects.toMatchObject({ code: 'command_spawn_failed' });
583
+ expect(abortListeners(controller.signal)).toBe(0);
584
+ expect(liveTreeCount()).toBe(0);
585
+ });
586
+ });
@@ -0,0 +1,153 @@
1
+ // One event connection per space, with its own state and its own backoff, attached to the
2
+ // host's event hub after the v2 translation layer: a space's events enter the hub as the
3
+ // host's do, numbered, coalesced, replayed and fanned out, marked with the space's id. They
4
+ // feed the same watcher, so live status, unread marks and notifications work for a space's
5
+ // sessions, and the same browser streams, so a client keeps one cursor for everything.
6
+ //
7
+ // Every event is untrusted and passes the session index first: a directory outside the
8
+ // space's root or a session id of the host's drops it. When a connection is made the host
9
+ // asks the space for its live status and enters it the same way. A gap is reported to the
10
+ // clients as `openchamber:space-stream`, so a client re-reads that one space.
11
+
12
+ import { Readable } from 'node:stream';
13
+
14
+ import { z } from 'zod';
15
+
16
+ import { createUpstreamSseReader } from '../event-stream/upstream-reader.js';
17
+
18
+ const EVENT_PATH = '/api/event';
19
+ const STATUS_PATH = '/api/sessions/status';
20
+ const STATUS_TIMEOUT_MS = 10_000;
21
+ const MAX_STATUS_BYTES = 4 * 1024 * 1024;
22
+
23
+ // Backoff between attempts: doubling from one second to a minute, reset by a connection.
24
+ const RECONNECT_BASE_MS = 1_000;
25
+ export const RECONNECT_CAP_MS = 60_000;
26
+ export const reconnectDelayAfter = (failures) => Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** Math.max(0, failures - 1));
27
+
28
+ // What the host reads of an answer from inside; the rest is carried, not read.
29
+ const liveStatusSchema = z.object({ status: z.enum(['busy', 'idle']) });
30
+ const statusSchema = z.object({ sessions: z.record(z.string(), z.unknown()).default({}) }).passthrough();
31
+ const eventPayloadSchema = z.object({ location: z.object({ directory: z.string() }).passthrough().optional() }).passthrough();
32
+
33
+ /** Reads a whole body up to a cap, as text. */
34
+ const readBody = (response, cap) => new Promise((resolve, reject) => {
35
+ const chunks = [];
36
+ let size = 0;
37
+ response.on('data', (chunk) => {
38
+ size += chunk.length;
39
+ if (size > cap) {
40
+ response.destroy();
41
+ reject(new Error(`the answer exceeds ${cap} bytes`));
42
+ return;
43
+ }
44
+ chunks.push(chunk);
45
+ });
46
+ response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
47
+ response.on('error', reject);
48
+ });
49
+
50
+ /**
51
+ * `requestInside(spaceId, { path, headers, timeoutMs })` is the dispatcher's; `index` the
52
+ * session index; `hub` the global event hub. `stallTimeoutMs` and `now` are for the tests.
53
+ */
54
+ export function createSpaceEventSources({ requestInside, index, hub, logger = console, stallTimeoutMs, now = Date.now }) {
55
+ const sources = new Map();
56
+
57
+ const inject = (spaceId, payload, directory) => hub.injectEvent({ payload, directory, spaceId });
58
+
59
+ const announce = (spaceId, status, wasReady) => {
60
+ inject(spaceId, { type: 'openchamber:space-stream', properties: { spaceId, status, wasReady, timestamp: now() } }, 'global');
61
+ };
62
+
63
+ /** The live status inside, entered as status events, so a busy session shows busy from the first moment. */
64
+ const seedStatus = async (spaceId) => {
65
+ const response = await requestInside(spaceId, { path: STATUS_PATH, headers: { accept: 'application/json' }, timeoutMs: STATUS_TIMEOUT_MS });
66
+ if (response.statusCode !== 200) {
67
+ response.resume();
68
+ throw new Error(`status ${response.statusCode}`);
69
+ }
70
+ const parsed = statusSchema.safeParse(JSON.parse(await readBody(response, MAX_STATUS_BYTES)));
71
+ if (!parsed.success) return;
72
+ for (const [sessionID, state] of Object.entries(parsed.data.sessions)) {
73
+ const live = liveStatusSchema.safeParse(state);
74
+ if (!live.success) continue;
75
+ const payload = { type: 'session.status', data: { sessionID, status: { type: live.data.status } } };
76
+ if (index.acceptSpaceEvent(spaceId, payload)) inject(spaceId, payload, '');
77
+ }
78
+ };
79
+
80
+ /** The event stream inside, over the dispatcher's channel, in the shape the reader expects. */
81
+ const fetchInside = (spaceId) => async (_url, { headers, signal }) => {
82
+ const response = await requestInside(spaceId, { path: EVENT_PATH, headers });
83
+ const stop = () => response.destroy();
84
+ if (signal?.aborted) { stop(); throw new Error('aborted'); }
85
+ signal?.addEventListener('abort', stop, { once: true });
86
+ const contentType = String(response.headers['content-type'] ?? '');
87
+ const ok = response.statusCode === 200 && contentType.includes('text/event-stream');
88
+ return { ok, status: response.statusCode, body: ok ? Readable.toWeb(response) : null };
89
+ };
90
+
91
+ const start = (spaceId) => {
92
+ if (sources.has(spaceId)) return;
93
+ const controller = new AbortController();
94
+ const source = { failures: 0, everConnected: false, reader: null, controller };
95
+ source.reader = createUpstreamSseReader({
96
+ signal: controller.signal,
97
+ fetchImpl: fetchInside(spaceId),
98
+ stallTimeoutMs,
99
+ reconnectDelayMs: () => reconnectDelayAfter(source.failures),
100
+ buildUrl: () => new URL(`http://space${EVENT_PATH}`),
101
+ onConnect() {
102
+ source.failures = 0;
103
+ const wasReady = source.everConnected;
104
+ source.everConnected = true;
105
+ announce(spaceId, 'connected', wasReady);
106
+ seedStatus(spaceId).catch((error) => logger.warn?.(`[spaces] could not read the status of space ${spaceId}: ${error?.code ?? error?.message ?? error}`));
107
+ },
108
+ onDisconnect({ reason }) {
109
+ source.failures += 1;
110
+ if (source.everConnected && reason !== 'stopped') announce(spaceId, 'disconnected', true);
111
+ },
112
+ onEvent({ payload }) {
113
+ const parsed = eventPayloadSchema.safeParse(payload);
114
+ if (!parsed.success || !index.acceptSpaceEvent(spaceId, payload)) return;
115
+ inject(spaceId, payload, parsed.data.location?.directory ?? '');
116
+ },
117
+ // Every failed attempt ends in `onDisconnect`, which is where it is counted.
118
+ onError(error) {
119
+ if (controller.signal.aborted) return;
120
+ logger.warn?.(`[spaces] event stream of space ${spaceId}: ${error?.type ?? 'error'} ${error?.status ?? error?.error?.code ?? ''}`.trim());
121
+ },
122
+ });
123
+ sources.set(spaceId, source);
124
+ void source.reader.start();
125
+ };
126
+
127
+ const stop = (spaceId) => {
128
+ const source = sources.get(spaceId);
129
+ if (!source) return;
130
+ sources.delete(spaceId);
131
+ source.controller.abort();
132
+ source.reader.stop();
133
+ };
134
+
135
+ return {
136
+ /** Follows the list of spaces: a connection for each new one, none for one that is gone. */
137
+ sync(spaceIds) {
138
+ const wanted = new Set(spaceIds);
139
+ for (const spaceId of Array.from(sources.keys())) {
140
+ if (!wanted.has(spaceId)) {
141
+ stop(spaceId);
142
+ index.forget(spaceId);
143
+ }
144
+ }
145
+ for (const spaceId of wanted) start(spaceId);
146
+ },
147
+ /** For the tests: whether a space has a connection right now. */
148
+ has: (spaceId) => sources.has(spaceId),
149
+ close() {
150
+ for (const spaceId of Array.from(sources.keys())) stop(spaceId);
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,160 @@
1
+ // The event connection of a space against a stand-in for the server inside, and the real hub.
2
+
3
+ import { PassThrough } from 'node:stream';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
8
+ import { SpaceError } from './errors.js';
9
+ import { RECONNECT_CAP_MS, createSpaceEventSources, reconnectDelayAfter } from './space-events.js';
10
+ import { createSpaceSessionIndex } from './space-sessions.js';
11
+
12
+ const ID = 'a1b2c3d4e5f6';
13
+ const OTHER = '0f0f0f0f0f0f';
14
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
15
+ const until = async (check, timeoutMs = 3_000) => {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (!check() && Date.now() < deadline) await sleep(10);
18
+ return check();
19
+ };
20
+
21
+ /** A response of the server inside: an SSE stream that the test writes to, or a JSON answer. */
22
+ const sseResponse = () => {
23
+ const stream = new PassThrough();
24
+ stream.statusCode = 200;
25
+ stream.headers = { 'content-type': 'text/event-stream' };
26
+ return stream;
27
+ };
28
+ const jsonResponse = (status, body) => {
29
+ const stream = new PassThrough();
30
+ stream.statusCode = status;
31
+ stream.headers = { 'content-type': 'application/json' };
32
+ stream.end(JSON.stringify(body));
33
+ return stream;
34
+ };
35
+
36
+ const makeHub = () => createGlobalMessageStreamHub({
37
+ buildOpenCodeUrl: (path) => `http://127.0.0.1:1${path}`,
38
+ getOpenCodeAuthHeaders: () => ({}),
39
+ deltaCoalesceWindowMs: 0,
40
+ fetchImpl: async () => { throw new Error('the host upstream is not part of this test'); },
41
+ });
42
+
43
+ describe('reconnectDelayAfter', () => {
44
+ it('doubles from one second and stops at a minute', () => {
45
+ expect([0, 1, 2, 3, 4].map(reconnectDelayAfter)).toEqual([1_000, 1_000, 2_000, 4_000, 8_000]);
46
+ expect(reconnectDelayAfter(20)).toBe(RECONNECT_CAP_MS);
47
+ });
48
+ });
49
+
50
+ describe('createSpaceEventSources', () => {
51
+ const streams = { [ID]: [], [OTHER]: [] };
52
+ const requests = [];
53
+ let status = { sessions: {} };
54
+ let refuse = null;
55
+ const requestInside = async (spaceId, request) => {
56
+ requests.push({ spaceId, path: request.path, headers: request.headers });
57
+ if (refuse) throw refuse;
58
+ if (request.path === '/api/sessions/status') return jsonResponse(200, status);
59
+ const stream = sseResponse();
60
+ streams[spaceId].push(stream);
61
+ return stream;
62
+ };
63
+ const logs = [];
64
+ const logger = { warn: (line) => logs.push(line) };
65
+ let hub;
66
+ let index;
67
+ let sources;
68
+ let received;
69
+
70
+ const start = () => {
71
+ hub = makeHub();
72
+ index = createSpaceSessionIndex({ logger });
73
+ received = [];
74
+ hub.subscribeEvent((event) => received.push(event), { spaces: true });
75
+ sources = createSpaceEventSources({ requestInside, index, hub, logger, stallTimeoutMs: 0 });
76
+ };
77
+
78
+ afterEach(() => {
79
+ sources?.close();
80
+ streams[ID].length = 0;
81
+ streams[OTHER].length = 0;
82
+ requests.length = 0;
83
+ logs.length = 0;
84
+ status = { sessions: {} };
85
+ refuse = null;
86
+ });
87
+
88
+ it('opens one connection per space, announces it, seeds the live status, and enters the events with the space id', async () => {
89
+ start();
90
+ status = { sessions: { busy1: { status: 'busy' }, idle1: { status: 'idle' }, odd: { status: 'retry' } } };
91
+ sources.sync([ID]);
92
+ expect(await until(() => streams[ID].length === 1)).toBe(true);
93
+ expect(requests[0]).toMatchObject({ spaceId: ID, path: '/api/event', headers: expect.objectContaining({ Accept: 'text/event-stream' }) });
94
+ expect(await until(() => received.length >= 3)).toBe(true);
95
+ expect(received[0].payload).toMatchObject({ type: 'openchamber:space-stream', properties: { spaceId: ID, status: 'connected', wasReady: false } });
96
+ expect(received.slice(1).map((event) => [event.payload.type, event.payload.data.sessionID, event.payload.data.status.type])).toEqual([
97
+ ['session.status', 'busy1', 'busy'],
98
+ ['session.status', 'idle1', 'idle'],
99
+ ]);
100
+
101
+ streams[ID][0].write(`data: ${JSON.stringify({ id: 'e1', type: 'session.execution.started', data: { sessionID: 's1' }, location: { directory: `/spaces/${ID}/repo` } })}\n\n`);
102
+ expect(await until(() => received.length === 4)).toBe(true);
103
+ expect(received[3]).toMatchObject({ spaceId: ID, directory: `/spaces/${ID}/repo`, payload: { id: 'e1' } });
104
+ expect(received[3].translated()).toEqual([expect.objectContaining({ type: 'session.status', properties: expect.objectContaining({ sessionID: 's1', directory: `/spaces/${ID}/repo` }) })]);
105
+ expect(sources.has(ID)).toBe(true);
106
+ });
107
+
108
+ it('drops an event that claims a directory outside the space or a session of the host', async () => {
109
+ start();
110
+ index.observeHostRecords([{ id: 'host-1' }]);
111
+ sources.sync([ID]);
112
+ expect(await until(() => streams[ID].length === 1)).toBe(true);
113
+ const stream = streams[ID][0];
114
+ const write = (payload) => stream.write(`data: ${JSON.stringify(payload)}\n\n`);
115
+ write({ id: 'bad1', type: 'session.execution.started', data: { sessionID: 's1' }, location: { directory: '/home/me/project' } });
116
+ write({ id: 'bad2', type: 'session.created', data: { sessionID: 's2', location: { directory: `/spaces/${OTHER}/repo` } }, location: { directory: `/spaces/${ID}/repo` } });
117
+ write({ id: 'bad3', type: 'session.execution.started', data: { sessionID: 'host-1' } });
118
+ write({ id: 'good', type: 'session.execution.started', data: { sessionID: 's1' } });
119
+ expect(await until(() => received.some((event) => event.payload.id === 'good'))).toBe(true);
120
+ expect(received.map((event) => event.payload.id).filter((id) => id?.startsWith('bad'))).toEqual([]);
121
+ expect(logs.filter((line) => line.includes('dropped an event'))).toHaveLength(3);
122
+ expect(logs.join('\n')).not.toContain('/home/me/project');
123
+ });
124
+
125
+ it('reconnects with backoff after the stream inside ends, announces the gap, and stops for a space that is gone', async () => {
126
+ start();
127
+ sources.sync([ID, OTHER]);
128
+ expect(await until(() => streams[ID].length === 1 && streams[OTHER].length === 1)).toBe(true);
129
+ expect(await until(() => received.filter((event) => event.payload.type === 'openchamber:space-stream').length === 2)).toBe(true);
130
+ streams[ID][0].end();
131
+ expect(await until(() => received.some((event) => event.payload.properties?.status === 'disconnected' && event.payload.properties?.spaceId === ID))).toBe(true);
132
+ // The first attempt after the gap waits a second, so it is not there at once.
133
+ await sleep(300);
134
+ expect(streams[ID]).toHaveLength(1);
135
+ expect(await until(() => streams[ID].length === 2, 3_000)).toBe(true);
136
+ expect(received.filter((event) => event.payload.properties?.spaceId === ID && event.payload.properties?.status === 'connected').at(-1).payload.properties.wasReady).toBe(true);
137
+ // A second drop in a row waits two seconds, not four: one failure is counted once.
138
+ const secondDropAt = Date.now();
139
+ streams[ID][1].end();
140
+ expect(await until(() => streams[ID].length === 3, 3_500)).toBe(true);
141
+ expect(Date.now() - secondDropAt).toBeLessThan(3_000);
142
+
143
+ index.acceptSpaceList(OTHER, [{ id: 'o1', location: { directory: `/spaces/${OTHER}/repo` } }], { complete: true });
144
+ sources.sync([ID]);
145
+ expect(sources.has(OTHER)).toBe(false);
146
+ expect(index.snapshot().map((entry) => entry.spaceId)).toEqual([]);
147
+ expect(streams[OTHER][0].destroyed).toBe(true);
148
+ }, 15_000);
149
+
150
+ it('keeps trying, quietly, while the space refuses the connection', async () => {
151
+ start();
152
+ refuse = new SpaceError('space_not_running', 'stopped');
153
+ sources.sync([ID]);
154
+ expect(await until(() => requests.length >= 1)).toBe(true);
155
+ await sleep(200);
156
+ expect(received).toEqual([]);
157
+ expect(requests.length).toBeLessThanOrEqual(2);
158
+ expect(logs.some((line) => line.includes(`event stream of space ${ID}`))).toBe(true);
159
+ });
160
+ });
@@ -0,0 +1,52 @@
1
+ // The one place that writes OpenCode's own files inside a space, so an OpenCode format change is
2
+ // a one-file edit (DESIGN.md, "Parts"). Today: the provider configuration that sends a provider's
3
+ // model calls through the gatekeeper's window. The login record for the short OpenAI token and
4
+ // the move of a chat to the host's archive belong here too, in their stages.
5
+ //
6
+ // What is written here cooperates and enforces nothing. The agent can change or delete the file;
7
+ // the gatekeeper is what keeps the key out of the space and the space away from the internet.
8
+
9
+ import { SpaceError } from './errors.js';
10
+ import { tail } from './exec-http.js';
11
+ import { IMAGE_ONLY_PATH, IMAGE_SH, SPACE_OPENCODE_CONFIG_DIRECTORY, SPACE_OPENCODE_CONFIG_PATH, spaceWindowUrl } from './layout.js';
12
+
13
+ // OpenCode takes a key from this option and its auth plugins stay out of the way; the window
14
+ // throws it away and puts the real one in its place. It is not a secret and never was one.
15
+ export const WINDOW_PLACEHOLDER_KEY = 'space-window';
16
+
17
+ // Through a temporary name, so OpenCode never reads half a file. The configuration is JSON on
18
+ // stdin: nothing of it is an argument.
19
+ const WRITE_CONFIG_SCRIPT = [
20
+ IMAGE_ONLY_PATH,
21
+ `mkdir -p ${SPACE_OPENCODE_CONFIG_DIRECTORY}`,
22
+ `&& cat > ${SPACE_OPENCODE_CONFIG_PATH}.new && mv ${SPACE_OPENCODE_CONFIG_PATH}.new ${SPACE_OPENCODE_CONFIG_PATH}`,
23
+ ].join(' ');
24
+
25
+ /**
26
+ * OpenCode's global configuration for a space with these model grants: each provider, under the
27
+ * id the host's catalog gives it, with its base URL at the window and a placeholder key. The
28
+ * composer offers the host's catalog, and the same provider id inside is what makes that choice
29
+ * work in the space unchanged. Decided with the maintainer on 2026-09-26.
30
+ */
31
+ export function buildProviderConfig(grants) {
32
+ const provider = {};
33
+ for (const grant of grants) {
34
+ if (grant.kind !== 'model') continue;
35
+ provider[grant.provider] = { options: { baseURL: spaceWindowUrl(grant.id), apiKey: WINDOW_PLACEHOLDER_KEY } };
36
+ }
37
+ return { $schema: 'https://opencode.ai/config.json', provider };
38
+ }
39
+
40
+ /** `exec` is the place operation, always for the space container. */
41
+ export function createSpaceOpenCode({ exec }) {
42
+ /** Writes the whole global configuration from the model grants the record holds. */
43
+ const writeProviderConfig = async (spaceId, grants) => {
44
+ const text = `${JSON.stringify(buildProviderConfig(grants), null, 2)}\n`;
45
+ const result = await exec(spaceId, [IMAGE_SH, '-c', WRITE_CONFIG_SCRIPT], { stdin: text });
46
+ if (result.code !== 0) {
47
+ throw new SpaceError('space_setup_failed', `Could not write the provider configuration inside the space: ${tail(result.stderr) || `exit code ${result.code}`}`);
48
+ }
49
+ };
50
+
51
+ return { writeProviderConfig };
52
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { WINDOW_PLACEHOLDER_KEY, buildProviderConfig, createSpaceOpenCode } from './space-opencode.js';
4
+
5
+ const ID = 'a1b2c3d4e5f6';
6
+ const GRANTS = [
7
+ { kind: 'model', id: 'anthropic', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', header: 'x-api-key', source: { kind: 'typed' } },
8
+ { kind: 'domain', id: 'open-0a1b2c3d4e5f', upstream: 'https://registry.example.com/npm/' },
9
+ { kind: 'model', id: 'openai', provider: 'openai', upstream: 'https://api.openai.com/v1', header: 'authorization', source: { kind: 'env', name: 'OPENAI_API_KEY' } },
10
+ ];
11
+
12
+ describe('the provider configuration inside a space', () => {
13
+ it('names each granted provider as the host does, at the window, with a placeholder key, and leaves domains out', () => {
14
+ expect(buildProviderConfig(GRANTS)).toEqual({
15
+ $schema: 'https://opencode.ai/config.json',
16
+ provider: {
17
+ anthropic: { options: { baseURL: 'http://gatekeeper:8080/model/anthropic', apiKey: WINDOW_PLACEHOLDER_KEY } },
18
+ openai: { options: { baseURL: 'http://gatekeeper:8080/model/openai', apiKey: WINDOW_PLACEHOLDER_KEY } },
19
+ },
20
+ });
21
+ expect(buildProviderConfig([])).toEqual({ $schema: 'https://opencode.ai/config.json', provider: {} });
22
+ // The upstream is the gatekeeper's business; nothing of it is written where the agent reads.
23
+ expect(JSON.stringify(buildProviderConfig(GRANTS))).not.toContain('api.anthropic.com');
24
+ });
25
+
26
+ it('writes the whole file over exec, as JSON on stdin, through a temporary name', async () => {
27
+ const calls = [];
28
+ const opencode = createSpaceOpenCode({ exec: async (spaceId, argv, options) => { calls.push({ spaceId, argv, options }); return { code: 0, stdout: '', stderr: '' }; } });
29
+ await opencode.writeProviderConfig(ID, GRANTS);
30
+ expect(calls).toEqual([{
31
+ spaceId: ID,
32
+ argv: ['/bin/sh', '-c', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; mkdir -p /home/space/.config/opencode && cat > /home/space/.config/opencode/opencode.json.new && mv /home/space/.config/opencode/opencode.json.new /home/space/.config/opencode/opencode.json'],
33
+ options: { stdin: `${JSON.stringify(buildProviderConfig(GRANTS), null, 2)}\n` },
34
+ }]);
35
+ });
36
+
37
+ it('says what failed inside', async () => {
38
+ const opencode = createSpaceOpenCode({ exec: async () => ({ code: 1, stdout: '', stderr: 'sh: cannot create: Read-only file system\n' }) });
39
+ await expect(opencode.writeProviderConfig(ID, GRANTS)).rejects.toMatchObject({ code: 'space_setup_failed', message: expect.stringContaining('Read-only file system') });
40
+ });
41
+ });