@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
@@ -0,0 +1,589 @@
1
+ // The journey over the memory place, with stand-ins for the gatekeeper channel, code in and code
2
+ // out that record what they were asked. What is under test is the order of the steps, what the
3
+ // host remembers, what it announces, and what a failure leaves.
4
+
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ import { afterEach, describe, expect, it } from 'vitest';
10
+
11
+ import { SpaceError } from './errors.js';
12
+ import { createSpaceJourney } from './journey.js';
13
+ import { createSpaceManager } from './manager.js';
14
+ import { createMemoryPlace } from './places/memory-place.js';
15
+ import { createPlaceRegistry } from './places/registry.js';
16
+ import { createSpaceRecords } from './space-records.js';
17
+
18
+ const PROJECT = '/home/me/project';
19
+ const NETWORK = { mode: 'allowlist', domains: ['api.anthropic.com'] };
20
+ const BASE = 'b'.repeat(40);
21
+ const quiet = { warn: () => {} };
22
+ const folders = [];
23
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
24
+ const until = async (check, timeoutMs = 3_000) => {
25
+ const deadline = Date.now() + timeoutMs;
26
+ while (!check() && Date.now() < deadline) await sleep(5);
27
+ return check();
28
+ };
29
+
30
+ afterEach(() => {
31
+ for (const folder of folders.splice(0)) fs.rmSync(folder, { recursive: true, force: true });
32
+ });
33
+
34
+ /** A journey on fresh stand-ins. `failAt` names a stand-in step that rejects. */
35
+ const journeyWith = ({ failAt = null, place = createMemoryPlace(), projects = [PROJECT], historyStatus = 'sent', holdCodeIn = false, holdCodeOut = false, hostEnvironment = {}, dataDir = null } = {}) => {
36
+ // With `holdCodeIn`, code in waits until the test lets it go, so a creation stays under way;
37
+ // `holdCodeOut` does the same for the fetch of an apply.
38
+ let releaseCodeIn = () => {};
39
+ const codeInHeld = new Promise((resolve) => { releaseCodeIn = resolve; });
40
+ let releaseCodeOut = () => {};
41
+ const codeOutHeld = new Promise((resolve) => { releaseCodeOut = resolve; });
42
+ if (dataDir === null) {
43
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-journey-'));
44
+ folders.push(dataDir);
45
+ }
46
+ const calls = [];
47
+ const events = [];
48
+ const changes = { count: 0 };
49
+ const fail = (step) => { if (failAt === step) throw new SpaceError(`${step}_failed`, `the stand-in for ${step} failed`); };
50
+ // The stand-in gatekeeper holds grant ids per space, like the program's memory: a stop forgets them.
51
+ const held = new Map();
52
+ const gatekeeper = {
53
+ setNetwork: async (spaceId, network) => { calls.push(['setNetwork', spaceId, network]); fail('setNetwork'); },
54
+ addGrant: async (spaceId, grant) => { calls.push(['addGrant', spaceId, grant]); fail('addGrant'); if (!held.has(spaceId)) held.set(spaceId, new Set()); held.get(spaceId).add(grant.id); },
55
+ readPolicy: async (spaceId) => { calls.push(['readPolicy', spaceId]); fail('readPolicy'); return { mode: 'allowlist', domains: [], grants: Array.from(held.get(spaceId) ?? []) }; },
56
+ readJournal: async (spaceId) => { calls.push(['readJournal', spaceId]); return { records: [], dropped: 0, since: '2026-09-26T10:00:00.000Z' }; },
57
+ forget: (spaceId) => { held.delete(spaceId); },
58
+ };
59
+ const spaceOpenCode = {
60
+ writeProviderConfig: async (spaceId, grants) => { calls.push(['writeProviderConfig', spaceId, grants]); fail('writeProviderConfig'); },
61
+ };
62
+ const codeIn = {
63
+ bringCodeIn: async (request) => {
64
+ calls.push(['bringCodeIn', request]);
65
+ if (holdCodeIn) await codeInHeld;
66
+ fail('bringCodeIn');
67
+ return { spacePath: `/spaces/${request.spaceId}/project`, projectPath: `/spaces/${request.spaceId}/project`, base: BASE, identityCopied: { name: true, email: false } };
68
+ },
69
+ sendHistory: async (request) => { calls.push(['sendHistory', request]); fail('sendHistory'); return { status: historyStatus }; },
70
+ removeSpaceRefs: async (request) => { calls.push(['removeSpaceRefs', request]); fail('removeSpaceRefs'); },
71
+ };
72
+ const codeOut = {
73
+ bringCodeOut: async (request) => { calls.push(['bringCodeOut', request]); if (holdCodeOut) await codeOutHeld; fail('bringCodeOut'); return { result: 'c'.repeat(40), changedPaths: 3, changedBytes: 10, nestedRepositories: { count: 0, paths: [] }, unmerged: { count: 0, paths: [] } }; },
74
+ describeApplyState: async (request) => { calls.push(['describeApplyState', request]); return { closed: false, newPaths: 3 }; },
75
+ applyAsBranch: async (request) => { calls.push(['applyAsBranch', request]); fail('applyAsBranch'); return { branch: request.branch, commit: 'c'.repeat(40) }; },
76
+ applyAsChanges: async (request) => { calls.push(['applyAsChanges', request]); fail('applyAsChanges'); return { status: failAt === 'nothing' ? 'nothing_to_apply' : 'applied', appliedPaths: 3, remembered: true }; },
77
+ };
78
+ const records = createSpaceRecords({ dataDir, logger: quiet });
79
+ const manager = createSpaceManager({ registry: createPlaceRegistry([place]), now: () => new Date('2026-09-26T10:00:00.000Z') });
80
+ const journey = createSpaceJourney({
81
+ manager, place, gatekeeper, codeIn, codeOut, records, spaceOpenCode,
82
+ listProjectDirectories: async () => projects,
83
+ readHostSecret: (name) => hostEnvironment[name],
84
+ announce: (spaceId, payload) => { events.push({ spaceId, ...payload.properties }); },
85
+ onSpacesChanged: () => { changes.count += 1; },
86
+ logger: quiet,
87
+ now: () => new Date('2026-09-26T10:00:00.000Z'),
88
+ });
89
+ return { journey, place, records, calls, events, changes, manager, releaseCodeIn, releaseCodeOut, gatekeeper, dataDir };
90
+ };
91
+
92
+ const REQUEST = { projectDirectory: PROJECT, name: ' Fix login ', start: 'uncommitted', network: NETWORK };
93
+ const steps = (events, spaceId) => events.filter((event) => event.spaceId === spaceId).map((event) => event.step);
94
+
95
+ describe('the journey: create', () => {
96
+ it('answers at once, then makes the space, sets its network, brings the code in and sends the history behind it', async () => {
97
+ const { journey, place, records, calls, events, changes } = journeyWith();
98
+
99
+ const answer = await journey.createSpace(REQUEST);
100
+ expect(answer).toMatchObject({ id: expect.stringMatching(/^[0-9a-f]{12}$/), name: 'Fix login', placeId: 'memory', projectDirectory: PROJECT, directory: `/spaces/${answer.id}/project`, state: 'preparing', step: 'checking_place', network: NETWORK, history: 'pending' });
101
+ // Listed as preparing while the steps run, whether or not the place has it yet.
102
+ expect((await journey.listSpaces()).find((space) => space.id === answer.id)).toMatchObject({ state: 'preparing' });
103
+
104
+ expect(await until(() => steps(events, answer.id).includes('ready'))).toBe(true);
105
+ expect(steps(events, answer.id)).toEqual(['checking_place', 'creating', 'setting_network', 'bringing_code', 'ready']);
106
+ expect(await place.list()).toEqual([expect.objectContaining({ id: answer.id, name: 'Fix login', state: 'running' })]);
107
+ expect(calls.map(([name]) => name).slice(0, 3)).toEqual(['setNetwork', 'bringCodeIn', 'sendHistory']);
108
+ expect(calls[0]).toEqual(['setNetwork', answer.id, NETWORK]);
109
+ expect(calls[1][1]).toEqual({ repository: PROJECT, spaceId: answer.id, mode: 'uncommitted' });
110
+ expect(calls[2][1]).toEqual({ repository: PROJECT, spaceId: answer.id, spacePath: `/spaces/${answer.id}/project`, base: BASE });
111
+ expect(await until(() => records.read(answer.id).record?.history === 'sent')).toBe(true);
112
+ expect(records.read(answer.id).record).toMatchObject({ network: NETWORK, repository: PROJECT, spacePath: `/spaces/${answer.id}/project`, base: BASE });
113
+ expect(changes.count).toBeGreaterThan(0);
114
+
115
+ const listed = await journey.listSpaces();
116
+ expect(listed).toEqual([expect.objectContaining({ id: answer.id, state: 'running', step: null, failure: null, network: NETWORK, history: 'sent', damaged: false })]);
117
+ });
118
+
119
+ it('keeps the space and marks the history failed when only the history did not arrive', async () => {
120
+ const { journey, records, events } = journeyWith({ failAt: 'sendHistory' });
121
+ const { id } = await journey.createSpace(REQUEST);
122
+ expect(await until(() => steps(events, id).includes('ready'))).toBe(true);
123
+ expect(await until(() => records.read(id).record?.history === 'failed')).toBe(true);
124
+ expect((await journey.listSpaces())[0]).toMatchObject({ id, state: 'running', history: 'failed' });
125
+ });
126
+
127
+ it.each(['setNetwork', 'bringCodeIn'])('removes what it made when %s fails, and lists the failure until it is dismissed', async (failAt) => {
128
+ const { journey, place, records, calls, events } = journeyWith({ failAt });
129
+ const { id } = await journey.createSpace(REQUEST);
130
+ expect(await until(() => steps(events, id).includes('failed'))).toBe(true);
131
+
132
+ expect(await place.list()).toEqual([]);
133
+ expect(records.read(id)).toEqual({ status: 'missing', record: null });
134
+ expect(calls.find(([name]) => name === 'removeSpaceRefs')?.[1]).toEqual({ repository: PROJECT, spaceId: id });
135
+ const failure = events.find((event) => event.spaceId === id && event.step === 'failed').failure;
136
+ expect(failure).toMatchObject({ code: `${failAt}_failed`, message: expect.stringContaining(failAt) });
137
+ expect(await journey.listSpaces()).toEqual([expect.objectContaining({ id, state: 'failed', step: 'failed', failure: expect.objectContaining({ code: `${failAt}_failed` }) })]);
138
+
139
+ // The failure is dismissed by removing it; the place is not asked, there is nothing there.
140
+ expect(await journey.removeSpace(id)).toEqual({ id, removed: true, refsRemoved: null, failures: [] });
141
+ expect(await journey.listSpaces()).toEqual([]);
142
+ });
143
+
144
+ it('does not make a space when the place is unavailable, and names the reason', async () => {
145
+ const place = { ...createMemoryPlace(), check: async () => ({ available: false, code: 'docker_daemon_unreachable', message: 'Docker is installed but not running.' }) };
146
+ const { journey, events } = journeyWith({ place });
147
+ const { id } = await journey.createSpace(REQUEST);
148
+ expect(await until(() => steps(events, id).includes('failed'))).toBe(true);
149
+ expect(steps(events, id)).toEqual(['checking_place', 'failed']);
150
+ expect(events.at(-1).failure).toMatchObject({ code: 'docker_daemon_unreachable', message: 'Docker is installed but not running.' });
151
+ expect(await place.list()).toEqual([]);
152
+ });
153
+
154
+ it('refuses an allowlist on a place that cannot keep the space away from its host', async () => {
155
+ const place = { ...createMemoryPlace(), check: async () => ({ available: true, version: '26.0.0', hostIsolation: false }) };
156
+ const { journey, events } = journeyWith({ place });
157
+ const { id } = await journey.createSpace(REQUEST);
158
+ expect(await until(() => steps(events, id).includes('failed'))).toBe(true);
159
+ expect(events.at(-1).failure.code).toBe('place_cannot_restrict_network');
160
+ expect(await place.list()).toEqual([]);
161
+ // Open is taken there, with the design's warning left to the funnel.
162
+ const open = await journey.createSpace({ ...REQUEST, network: { mode: 'open' } });
163
+ expect(await until(() => steps(events, open.id).includes('ready'))).toBe(true);
164
+ });
165
+
166
+ it('refuses a bad request before anything runs', async () => {
167
+ const { journey, place, events } = journeyWith();
168
+ await expect(journey.createSpace({ ...REQUEST, projectDirectory: '/home/me/other' })).rejects.toMatchObject({ code: 'project_not_registered' });
169
+ await expect(journey.createSpace({ ...REQUEST, projectDirectory: '' })).rejects.toMatchObject({ code: 'project_not_registered' });
170
+ await expect(journey.createSpace({ ...REQUEST, start: 'yesterday' })).rejects.toMatchObject({ code: 'invalid_snapshot_mode' });
171
+ await expect(journey.createSpace({ ...REQUEST, network: { mode: 'allowlist', domains: ['10.0.0.1'] } })).rejects.toMatchObject({ code: 'invalid_network' });
172
+ await expect(journey.createSpace({ ...REQUEST, network: { mode: 'everything' } })).rejects.toMatchObject({ code: 'invalid_network' });
173
+ await expect(journey.createSpace({ ...REQUEST, name: ' ' })).rejects.toMatchObject({ code: 'invalid_space_name' });
174
+ await expect(journey.createSpace({ ...REQUEST, name: 7 })).rejects.toMatchObject({ code: 'invalid_space_name' });
175
+ expect(events).toEqual([]);
176
+ expect(await place.list()).toEqual([]);
177
+ });
178
+ });
179
+
180
+ describe('the journey: start, stop, remove', () => {
181
+ const ready = async (options) => {
182
+ const made = journeyWith(options);
183
+ const { id } = await made.journey.createSpace(REQUEST);
184
+ await until(() => steps(made.events, id).includes('ready'));
185
+ await until(() => made.records.read(id).record?.history !== 'pending');
186
+ made.calls.splice(0);
187
+ return { ...made, id };
188
+ };
189
+
190
+ it('stops a space and starts it again with its network said again to the gatekeeper', async () => {
191
+ const { journey, place, calls, id, changes } = await ready();
192
+ const before = changes.count;
193
+ expect(await journey.stopSpace(id)).toMatchObject({ id, state: 'exited', network: NETWORK });
194
+ expect(await place.list()).toEqual([expect.objectContaining({ state: 'exited' })]);
195
+ expect(calls).toEqual([]);
196
+
197
+ const started = await journey.startSpace(id);
198
+ expect(started).toMatchObject({ id, state: 'running', networkRestored: true });
199
+ expect(calls).toEqual([['setNetwork', id, NETWORK]]);
200
+ expect(changes.count).toBe(before + 2);
201
+ });
202
+
203
+ it('starts a space whose record is gone with its network left closed, and says so', async () => {
204
+ const { journey, records, calls, id } = await ready();
205
+ await journey.stopSpace(id);
206
+ records.remove(id);
207
+ expect(await journey.startSpace(id)).toMatchObject({ id, state: 'running', networkRestored: false, network: null, history: 'unknown' });
208
+ expect(calls).toEqual([]);
209
+ });
210
+
211
+ it('sends the history again at start when it never arrived or failed', async () => {
212
+ const { journey, records, calls, id } = await ready({ failAt: 'sendHistory' });
213
+ expect(records.read(id).record.history).toBe('failed');
214
+ await journey.stopSpace(id);
215
+ await journey.startSpace(id);
216
+ expect(calls.map(([name]) => name)).toEqual(['setNetwork', 'sendHistory']);
217
+ calls.splice(0);
218
+ records.update(id, { history: 'sent' });
219
+ await journey.stopSpace(id);
220
+ await journey.startSpace(id);
221
+ expect(calls.map(([name]) => name)).toEqual(['setNetwork']);
222
+ });
223
+
224
+ it('removes the space, the refs in the user\'s repository and the record', async () => {
225
+ const { journey, place, records, calls, id } = await ready();
226
+ expect(await journey.removeSpace(id)).toEqual({ id, removed: true, refsRemoved: true, failures: [] });
227
+ expect(calls).toEqual([['removeSpaceRefs', { repository: PROJECT, spaceId: id }]]);
228
+ expect(await place.list()).toEqual([]);
229
+ expect(records.read(id).status).toBe('missing');
230
+ await expect(journey.removeSpace(id)).rejects.toMatchObject({ code: 'space_not_found' });
231
+ });
232
+
233
+ it('reports refs that could not be removed, with the space gone all the same', async () => {
234
+ const { journey, place, id } = await ready({ failAt: 'removeSpaceRefs' });
235
+ const outcome = await journey.removeSpace(id);
236
+ expect(outcome).toMatchObject({ id, removed: true, refsRemoved: false, failures: [expect.objectContaining({ code: 'removeSpaceRefs_failed' })] });
237
+ expect(await place.list()).toEqual([]);
238
+ });
239
+
240
+ it('refuses to start, stop or remove a space that is still being made', async () => {
241
+ const { journey, events, releaseCodeIn } = journeyWith({ holdCodeIn: true });
242
+ const { id } = await journey.createSpace(REQUEST);
243
+ expect(await until(() => steps(events, id).includes('bringing_code'))).toBe(true);
244
+ await expect(journey.stopSpace(id)).rejects.toMatchObject({ code: 'space_preparing' });
245
+ await expect(journey.startSpace(id)).rejects.toMatchObject({ code: 'space_preparing' });
246
+ await expect(journey.removeSpace(id)).rejects.toMatchObject({ code: 'space_preparing' });
247
+ await expect(journey.previewApply(id)).rejects.toMatchObject({ code: 'space_preparing' });
248
+ releaseCodeIn();
249
+ expect(await until(() => steps(events, id).includes('ready'))).toBe(true);
250
+ expect(await journey.stopSpace(id)).toMatchObject({ state: 'exited' });
251
+ });
252
+
253
+ it('stops every running space for the switch, and reports the one that would not stop as still running', async () => {
254
+ const { journey, place, manager } = await ready();
255
+ const second = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'Second' });
256
+ const third = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'Third' });
257
+ await place.stop(third.id);
258
+ const stop = place.stop;
259
+ place.stop = async (spaceId) => { if (spaceId === second.id) throw new SpaceError('docker_command_failed', 'docker stop exited 1'); return stop(spaceId); };
260
+
261
+ const outcome = await journey.stopAllSpaces();
262
+ expect(outcome.stopped).toEqual([expect.objectContaining({ name: 'Fix login' })]);
263
+ expect(outcome.stillRunning).toEqual([{ id: second.id, name: 'Second', code: 'docker_command_failed', message: 'docker stop exited 1', details: null }]);
264
+ expect((await place.list()).map((space) => space.state).sort()).toEqual(['exited', 'exited', 'running']);
265
+ });
266
+
267
+ it('does not turn the switch off while a space is being made', async () => {
268
+ const { journey, events, releaseCodeIn } = journeyWith({ holdCodeIn: true });
269
+ const { id } = await journey.createSpace(REQUEST);
270
+ expect(await until(() => steps(events, id).includes('bringing_code'))).toBe(true);
271
+ await expect(journey.stopAllSpaces()).rejects.toMatchObject({ code: 'space_preparing', details: { spaces: [id] } });
272
+ releaseCodeIn();
273
+ expect(await until(() => steps(events, id).includes('ready'))).toBe(true);
274
+ expect(await journey.stopAllSpaces()).toEqual({ stopped: [{ id, name: 'Fix login' }], stillRunning: [] });
275
+ });
276
+
277
+ it('takes no creation and no start once the switch is being turned off, until the turn-off is undone', async () => {
278
+ const { journey, manager } = journeyWith();
279
+ const stopped = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'Stopped' });
280
+ await journey.stopAllSpaces();
281
+ await expect(journey.createSpace(REQUEST)).rejects.toMatchObject({ code: 'isolated_spaces_off' });
282
+ await expect(journey.startSpace(stopped.id)).rejects.toMatchObject({ code: 'isolated_spaces_off' });
283
+ journey.reopen();
284
+ expect((await journey.startSpace(stopped.id)).state).toBe('running');
285
+ const { id } = await journey.createSpace(REQUEST);
286
+ expect(id).toMatch(/^[0-9a-f]{12}$/);
287
+ });
288
+
289
+ it('removes the containers of a failed creation whose clean-up failed when it is dismissed', async () => {
290
+ const place = createMemoryPlace();
291
+ const remove = place.remove;
292
+ let refusals = 1;
293
+ place.remove = async (spaceId) => {
294
+ if (refusals > 0) { refusals -= 1; throw new SpaceError('docker_command_failed', 'docker rm exited 1'); }
295
+ return remove(spaceId);
296
+ };
297
+ const { journey, events } = journeyWith({ place, failAt: 'bringCodeIn' });
298
+ const { id } = await journey.createSpace(REQUEST);
299
+ expect(await until(() => steps(events, id).includes('failed'))).toBe(true);
300
+ expect(events.at(-1).failure.cleanup).toEqual([expect.objectContaining({ code: 'docker_command_failed' })]);
301
+ expect(await place.list()).toHaveLength(1);
302
+ await expect(journey.startSpace(id)).rejects.toMatchObject({ code: 'space_creation_failed' });
303
+
304
+ expect(await journey.removeSpace(id)).toMatchObject({ id, removed: true });
305
+ expect(await place.list()).toEqual([]);
306
+ expect(await journey.listSpaces()).toEqual([]);
307
+ });
308
+ });
309
+
310
+ describe('the journey: grants', () => {
311
+ const KEY = 'sk-live-typed-once';
312
+ const ENV_KEY = 'sk-live-from-the-host-environment';
313
+ const ready = async (options) => {
314
+ const made = journeyWith(options);
315
+ const { id } = await made.journey.createSpace(REQUEST);
316
+ await until(() => steps(made.events, id).includes('ready'));
317
+ await until(() => made.records.read(id).record?.history !== 'pending');
318
+ made.calls.splice(0);
319
+ return { ...made, id };
320
+ };
321
+ const anthropic = { kind: 'model', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', secret: { kind: 'typed', value: KEY } };
322
+ const openai = { kind: 'model', provider: 'openai', upstream: 'https://api.openai.com/v1', secret: { kind: 'env', name: 'OPENAI_API_KEY' } };
323
+ const registry = { kind: 'domain', upstream: 'https://registry.example.com/npm/' };
324
+
325
+ it('gives a model key to the gatekeeper, remembers the grant without the key, and points OpenCode inside at the window', async () => {
326
+ const { journey, records, calls, id } = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
327
+ const typed = await journey.grantAccess(id, anthropic);
328
+ expect(typed).toEqual({ grant: { kind: 'model', id: 'anthropic', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', header: 'x-api-key', source: { kind: 'typed' }, url: 'http://gatekeeper:8080/model/anthropic' } });
329
+ const fromEnv = await journey.grantAccess(id, openai);
330
+ expect(fromEnv.grant).toMatchObject({ id: 'openai', header: 'authorization', source: { kind: 'env', name: 'OPENAI_API_KEY' } });
331
+
332
+ expect(calls).toEqual([
333
+ ['addGrant', id, { id: 'anthropic', upstream: 'https://api.anthropic.com/v1', header: 'x-api-key', secret: KEY }],
334
+ ['writeProviderConfig', id, [expect.objectContaining({ id: 'anthropic' })]],
335
+ ['addGrant', id, { id: 'openai', upstream: 'https://api.openai.com/v1', header: 'authorization', secret: ENV_KEY }],
336
+ ['writeProviderConfig', id, [expect.objectContaining({ id: 'anthropic' }), expect.objectContaining({ id: 'openai' })]],
337
+ ]);
338
+ // The record holds both grants and neither key, in the file as on the way out.
339
+ const { record } = records.read(id);
340
+ expect(record.grants.map((grant) => grant.id)).toEqual(['anthropic', 'openai']);
341
+ const onDisk = JSON.stringify(record);
342
+ expect(onDisk).not.toContain(KEY);
343
+ expect(onDisk).not.toContain(ENV_KEY);
344
+ expect(JSON.stringify(await journey.listSpaces({ access: true }))).not.toContain(KEY);
345
+ });
346
+
347
+ it('replaces the grant of the same provider, and opens a domain with no key and no header', async () => {
348
+ const { journey, records, calls, id } = await ready();
349
+ await journey.grantAccess(id, anthropic);
350
+ await journey.grantAccess(id, { ...anthropic, secret: { kind: 'typed', value: 'sk-live-newer' } });
351
+ expect(records.read(id).record.grants).toHaveLength(1);
352
+ expect(calls.filter(([name]) => name === 'addGrant').at(-1)[2].secret).toBe('sk-live-newer');
353
+
354
+ calls.splice(0);
355
+ const opened = await journey.grantAccess(id, registry);
356
+ expect(opened.grant).toEqual({ kind: 'domain', id: expect.stringMatching(/^open-[0-9a-f]{12}$/), upstream: 'https://registry.example.com/npm/', url: `http://gatekeeper:8080/model/${opened.grant.id}` });
357
+ // No header, no secret, and OpenCode inside is not told about a domain.
358
+ expect(calls).toEqual([['addGrant', id, { id: opened.grant.id, upstream: 'https://registry.example.com/npm/', header: null, secret: null }]]);
359
+ expect(records.read(id).record.grants.map((grant) => grant.kind)).toEqual(['model', 'domain']);
360
+ });
361
+
362
+ it('refuses a grant it cannot give: a bad request, a stopped space, a key the host cannot find, a record it cannot read', async () => {
363
+ const { journey, records, calls, id } = await ready();
364
+ for (const bad of [
365
+ { kind: 'model', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1' },
366
+ { kind: 'model', provider: 'anthropic', upstream: 'ftp://api.anthropic.com/v1', secret: { kind: 'typed', value: KEY } },
367
+ { kind: 'model', provider: 'anthropic', upstream: 'not a url', secret: { kind: 'typed', value: KEY } },
368
+ { kind: 'domain', upstream: 'not a url' },
369
+ { kind: 'model', provider: '../x', upstream: 'https://api.anthropic.com/v1', secret: { kind: 'typed', value: KEY } },
370
+ { kind: 'model', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', secret: { kind: 'typed', value: '' } },
371
+ { kind: 'model', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', secret: { kind: 'env', name: 'not a name' } },
372
+ { kind: 'model', provider: 'anthropic', upstream: 'https://api.anthropic.com/v1', secret: { kind: 'file', name: '/tmp/key' } },
373
+ { kind: 'domain', upstream: 'https://registry.example.com/', secret: { kind: 'typed', value: KEY } },
374
+ { kind: 'ssh' },
375
+ null,
376
+ ]) {
377
+ await expect(journey.grantAccess(id, bad), JSON.stringify(bad)).rejects.toMatchObject({ code: 'invalid_grant_request' });
378
+ }
379
+ await expect(journey.grantAccess(id, openai)).rejects.toMatchObject({ code: 'secret_source_missing', message: expect.stringContaining('OPENAI_API_KEY') });
380
+ // A provider that reads its key another way is refused, never accepted to fail every turn.
381
+ await expect(journey.grantAccess(id, { ...anthropic, provider: 'azure' })).rejects.toMatchObject({ code: 'provider_not_supported' });
382
+ expect(calls).toEqual([]);
383
+ expect(records.read(id).record.grants).toEqual([]);
384
+
385
+ records.remove(id);
386
+ await expect(journey.grantAccess(id, anthropic)).rejects.toMatchObject({ code: 'space_record_unreadable' });
387
+ await journey.stopSpace(id);
388
+ await expect(journey.grantAccess(id, anthropic)).rejects.toMatchObject({ code: 'space_not_running' });
389
+ expect(calls.filter(([name]) => name === 'addGrant')).toEqual([]);
390
+ await expect(journey.grantAccess('0f0f0f0f0f0f', anthropic)).rejects.toMatchObject({ code: 'space_not_found' });
391
+ });
392
+
393
+ it('keeps a grant whose configuration inside was not written, and writes it again at the next start', async () => {
394
+ const { journey, records, calls, place, dataDir, id } = await ready();
395
+ // A second journey over the same place and records, whose write inside fails.
396
+ const failing = journeyWith({ place, dataDir, failAt: 'writeProviderConfig' });
397
+ await expect(failing.journey.grantAccess(id, anthropic)).rejects.toMatchObject({ code: 'writeProviderConfig_failed' });
398
+ // The key reached the gatekeeper and the grant is remembered: only the cooperation inside is missing.
399
+ expect(records.read(id).record.grants.map((grant) => grant.id)).toEqual(['anthropic']);
400
+
401
+ await journey.stopSpace(id);
402
+ calls.splice(0);
403
+ await journey.startSpace(id);
404
+ expect(calls.filter(([name]) => name === 'writeProviderConfig')).toEqual([['writeProviderConfig', id, [expect.objectContaining({ id: 'anthropic' })]]]);
405
+ // And a start whose write fails still starts.
406
+ await failing.journey.stopSpace(id);
407
+ expect(await failing.journey.startSpace(id)).toMatchObject({ state: 'running', networkRestored: true });
408
+ });
409
+
410
+ it('says nothing about access while an action holds the space', async () => {
411
+ const { journey, id, releaseCodeOut } = await ready({ holdCodeOut: true });
412
+ await journey.grantAccess(id, registry);
413
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'granted' });
414
+ const applying = journey.applySpace(id, { as: 'changes' });
415
+ await sleep(20);
416
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: null, needsAccess: [] });
417
+ releaseCodeOut();
418
+ await applying;
419
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'granted' });
420
+ });
421
+
422
+ it('does not remember a grant the gatekeeper refused', async () => {
423
+ const { journey, records, id } = await ready({ failAt: 'addGrant' });
424
+ await expect(journey.grantAccess(id, anthropic)).rejects.toMatchObject({ code: 'addGrant_failed' });
425
+ expect(records.read(id).record.grants).toEqual([]);
426
+ });
427
+
428
+ it('says the grants again after a start: from the host environment yes, a typed key no, a domain yes', async () => {
429
+ const { journey, calls, gatekeeper, id } = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
430
+ await journey.grantAccess(id, anthropic);
431
+ await journey.grantAccess(id, openai);
432
+ const opened = await journey.grantAccess(id, registry);
433
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'granted', needsAccess: [] });
434
+
435
+ await journey.stopSpace(id);
436
+ gatekeeper.forget(id);
437
+ calls.splice(0);
438
+ const started = await journey.startSpace(id);
439
+ expect(started).toMatchObject({ networkRestored: true, grantsRestored: ['openai', opened.grant.id], needsAccess: ['anthropic'], access: null });
440
+ expect(calls.filter(([name]) => name === 'addGrant')).toEqual([
441
+ ['addGrant', id, { id: 'openai', upstream: 'https://api.openai.com/v1', header: 'authorization', secret: ENV_KEY }],
442
+ ['addGrant', id, { id: opened.grant.id, upstream: 'https://registry.example.com/npm/', header: null, secret: null }],
443
+ ]);
444
+ // The typed key is not in anything the start sent or wrote.
445
+ expect(JSON.stringify(calls)).not.toContain(KEY);
446
+ // OpenCode's configuration inside is written again from the record, with both model grants,
447
+ // so a write that failed at the grant is repaired here. It holds no key.
448
+ expect(calls.filter(([name]) => name === 'writeProviderConfig')).toEqual([['writeProviderConfig', id, [expect.objectContaining({ id: 'anthropic' }), expect.objectContaining({ id: 'openai' }), expect.objectContaining({ id: opened.grant.id })]]]);
449
+
450
+ // The list asks the gatekeeper and says which grant needs the user again.
451
+ const listed = (await journey.listSpaces({ access: true }))[0];
452
+ expect(listed).toMatchObject({ access: 'needs_access', needsAccess: ['anthropic'] });
453
+ expect(listed.grants.map((grant) => grant.id)).toEqual(['anthropic', 'openai', opened.grant.id]);
454
+ // Granting once more clears it.
455
+ await journey.grantAccess(id, anthropic);
456
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'granted', needsAccess: [] });
457
+ });
458
+
459
+ it('needs the user again after a start when the host environment no longer has the key, and when the gatekeeper refuses', async () => {
460
+ const withKey = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
461
+ await withKey.journey.grantAccess(withKey.id, openai);
462
+ await withKey.journey.stopSpace(withKey.id);
463
+ withKey.gatekeeper.forget(withKey.id);
464
+ // The same records, read by a journey whose host has no such variable now: nothing is sent, and the start says so.
465
+ const without = journeyWith({ place: withKey.place, dataDir: withKey.dataDir, hostEnvironment: {} });
466
+ expect(await without.journey.startSpace(withKey.id)).toMatchObject({ state: 'running', networkRestored: true, grantsRestored: [], needsAccess: ['openai'] });
467
+ expect(without.calls.filter(([name]) => name === 'addGrant')).toEqual([]);
468
+ expect((await without.journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'needs_access', needsAccess: ['openai'] });
469
+
470
+ // A gatekeeper that refuses the grant at the start leaves the space running and the grant missing, never "restored".
471
+ const refusing = await ready({ hostEnvironment: { OPENAI_API_KEY: ENV_KEY } });
472
+ await refusing.journey.grantAccess(refusing.id, openai);
473
+ await refusing.journey.stopSpace(refusing.id);
474
+ refusing.gatekeeper.forget(refusing.id);
475
+ refusing.gatekeeper.addGrant = async () => { throw new SpaceError('gatekeeper_refused', 'The gatekeeper refused to add the grant'); };
476
+ expect(await refusing.journey.startSpace(refusing.id)).toMatchObject({ state: 'running', grantsRestored: [], needsAccess: ['openai'] });
477
+ expect((await refusing.journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'needs_access', needsAccess: ['openai'] });
478
+ });
479
+
480
+ it('asks the gatekeeper only where there is something to ask, and never calls a failed read "granted"', async () => {
481
+ const { journey, calls, id, manager, place } = await ready();
482
+ const empty = await manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'No grants' });
483
+ await place.stop(empty.id);
484
+ calls.splice(0);
485
+ // Not asked: no grants on the first, the second is stopped, and nobody asked for access.
486
+ expect((await journey.listSpaces()).map((space) => space.access)).toEqual([null, null]);
487
+ expect((await journey.listSpaces({ access: true })).map((space) => space.access)).toEqual([null, null]);
488
+ expect(calls.filter(([name]) => name === 'readPolicy')).toEqual([]);
489
+
490
+ await journey.grantAccess(id, anthropic);
491
+ calls.splice(0);
492
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'granted' });
493
+ expect(calls.filter(([name]) => name === 'readPolicy')).toEqual([['readPolicy', id]]);
494
+ // A stopped space with grants is not asked either: its gatekeeper holds nothing and the next start says the grants again.
495
+ await journey.stopSpace(id);
496
+ expect((await journey.listSpaces({ access: true }))[0]).toMatchObject({ state: 'exited', access: null, grants: [expect.objectContaining({ id: 'anthropic' })] });
497
+
498
+ const failing = await ready({ failAt: 'readPolicy' });
499
+ await failing.journey.grantAccess(failing.id, anthropic);
500
+ expect((await failing.journey.listSpaces({ access: true }))[0]).toMatchObject({ access: 'unknown', needsAccess: [] });
501
+ });
502
+ });
503
+
504
+ describe('the journey: journal and apply', () => {
505
+ const ready = async (options) => {
506
+ const made = journeyWith(options);
507
+ const { id } = await made.journey.createSpace(REQUEST);
508
+ await until(() => steps(made.events, id).includes('ready'));
509
+ await until(() => made.records.read(id).record?.history !== 'pending');
510
+ made.calls.splice(0);
511
+ return { ...made, id };
512
+ };
513
+
514
+ it('reads the journal of a running space and refuses one that is stopped, because the record is gone', async () => {
515
+ const { journey, id, calls } = await ready();
516
+ expect(await journey.readJournal(id)).toEqual({ records: [], dropped: 0, since: '2026-09-26T10:00:00.000Z' });
517
+ expect(calls).toEqual([['readJournal', id]]);
518
+ await journey.stopSpace(id);
519
+ await expect(journey.readJournal(id)).rejects.toMatchObject({ code: 'space_not_running', message: expect.stringContaining('gone') });
520
+ await expect(journey.readJournal('0f0f0f0f0f0f')).rejects.toMatchObject({ code: 'space_not_found' });
521
+ });
522
+
523
+ it('previews an apply by bringing the work out and describing the state, writing nothing', async () => {
524
+ const { journey, id, calls } = await ready();
525
+ const preview = await journey.previewApply(id);
526
+ expect(preview).toMatchObject({ result: 'c'.repeat(40), changedPaths: 3, closed: false, newPaths: 3 });
527
+ expect(calls).toEqual([
528
+ ['bringCodeOut', { repository: PROJECT, spaceId: id, spacePath: `/spaces/${id}/project` }],
529
+ ['describeApplyState', { repository: PROJECT, spaceId: id }],
530
+ ]);
531
+ });
532
+
533
+ it('applies as a branch, then removes the space when asked, and only after the apply went through', async () => {
534
+ const { journey, place, id, calls } = await ready();
535
+ const outcome = await journey.applySpace(id, { as: 'branch', branch: 'space/fix-login', removeAfterwards: true });
536
+ expect(outcome.applied).toEqual({ status: 'applied', branch: 'space/fix-login', commit: 'c'.repeat(40) });
537
+ expect(outcome.removal).toMatchObject({ id, removed: true, refsRemoved: true });
538
+ expect(calls.map(([name]) => name)).toEqual(['bringCodeOut', 'applyAsBranch', 'removeSpaceRefs']);
539
+ expect(await place.list()).toEqual([]);
540
+ });
541
+
542
+ it('applies as changes and keeps the space when not asked to remove it', async () => {
543
+ const { journey, place, id, calls } = await ready();
544
+ const outcome = await journey.applySpace(id, { as: 'changes' });
545
+ expect(outcome.applied).toEqual({ status: 'applied', appliedPaths: 3, remembered: true });
546
+ expect(outcome.removal).toBeNull();
547
+ expect(calls.map(([name]) => name)).toEqual(['bringCodeOut', 'applyAsChanges']);
548
+ expect(await place.list()).toHaveLength(1);
549
+ });
550
+
551
+ it('does not remove the space when there was nothing to apply, or when the apply refused', async () => {
552
+ const nothing = await ready({ failAt: 'nothing' });
553
+ expect((await nothing.journey.applySpace(nothing.id, { as: 'changes', removeAfterwards: true })).removal).toBeNull();
554
+ expect(await nothing.place.list()).toHaveLength(1);
555
+
556
+ const refused = await ready({ failAt: 'applyAsChanges' });
557
+ await expect(refused.journey.applySpace(refused.id, { as: 'changes', removeAfterwards: true })).rejects.toMatchObject({ code: 'applyAsChanges_failed' });
558
+ expect(await refused.place.list()).toHaveLength(1);
559
+ });
560
+
561
+ it('runs one action per space at a time: a remove during an apply is refused as busy', async () => {
562
+ const { journey, place, id, releaseCodeOut } = await ready({ holdCodeOut: true });
563
+ const applying = journey.applySpace(id, { as: 'changes' });
564
+ await sleep(20);
565
+ await expect(journey.removeSpace(id)).rejects.toMatchObject({ code: 'space_busy' });
566
+ await expect(journey.stopSpace(id)).rejects.toMatchObject({ code: 'space_busy' });
567
+ await expect(journey.readJournal(id)).rejects.toMatchObject({ code: 'space_busy' });
568
+ // Other spaces are not held up.
569
+ const other = await journey.createSpace({ ...REQUEST, name: 'Other' });
570
+ releaseCodeOut();
571
+ expect((await applying).applied.status).toBe('applied');
572
+ expect(await journey.removeSpace(id)).toMatchObject({ removed: true });
573
+ expect((await place.list()).map((space) => space.id)).toEqual([other.id]);
574
+ });
575
+
576
+ it('refuses an apply it cannot place: a bad way, a space still being made, or a project no longer registered', async () => {
577
+ const { journey, id, records, calls } = await ready();
578
+ await expect(journey.applySpace(id, { as: 'merge' })).rejects.toMatchObject({ code: 'invalid_apply_request' });
579
+ records.remove(id);
580
+ // Without a record the registered project still says where the work goes.
581
+ await journey.previewApply(id);
582
+ expect(calls[0]).toEqual(['bringCodeOut', { repository: PROJECT, spaceId: id, spacePath: `/spaces/${id}/project` }]);
583
+
584
+ const orphan = journeyWith({ projects: [] });
585
+ const other = await orphan.manager.createSpace({ placeId: 'memory', projectDirectory: PROJECT, name: 'Orphan' });
586
+ await expect(orphan.journey.previewApply(other.id)).rejects.toMatchObject({ code: 'project_not_registered' });
587
+ expect((await orphan.journey.listSpaces())[0]).toMatchObject({ id: other.id, projectDirectory: null, directory: null });
588
+ });
589
+ });