@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
@@ -10,6 +10,7 @@
10
10
  // spaceMetadata(spaceId) resolves, as text, everything the place's own records show about the space container
11
11
  // gatekeeperMetadata(spaceId) the same for the gatekeeper container
12
12
  // gatekeeperAddresses(spaceId) resolves { inner, outer }, the gatekeeper's own addresses
13
+ // probeFromOuterNetwork(spaceId, script) runs a Node script in a helper on the space's outer network
13
14
  // startWindowUpstream(spaceId) a stand-in provider on the space's outer network. It answers with
14
15
  // the sha256 of the credentials it saw, never with the credentials
15
16
 
@@ -190,17 +191,51 @@ Promise.all(Array.from({ length: 80 }, attempt)).then((outcomes) => {
190
191
  const LONG_LIVED_CREDENTIAL = '"refresh"[[:space:]]*:[[:space:]]*"|BEGIN [A-Z ]*PRIVATE KEY|gh[pousr]_[A-Za-z0-9]{16}|sk-[A-Za-z0-9_-]{20}|xox[baprs]-';
191
192
 
192
193
  /** Everything the space holds that could be a long-lived credential, as text for the host to read.
193
- * HOME, the work directory and /tmp, which is writable and 256 MiB. */
194
+ * HOME, the work directory and /tmp, which is writable and 256 MiB. `-a` reads binary files as
195
+ * text: OpenCode 2 keeps its logins in the SQLite database `opencode.db` under HOME, and `-I`
196
+ * skipped that file. `-o` prints the matched shape and its file, never the binary around it. */
194
197
  const credentialSearch = (spaceId) => `
195
198
  PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;
196
199
  echo "auth-record: $([ -e "$HOME/.local/share/opencode/auth.json" ] && echo present || echo absent)";
197
200
  echo "--- files";
198
- grep -rIsE '${LONG_LIVED_CREDENTIAL}' "$HOME" /tmp /spaces/${spaceId} 2>/dev/null | head -20;
201
+ grep -raosE '${LONG_LIVED_CREDENTIAL}' "$HOME" /tmp /spaces/${spaceId} 2>/dev/null | head -20;
199
202
  echo "--- environments";
200
203
  for process in /proc/[0-9]*/environ; do tr '\\0' '\\n' < "$process" 2>/dev/null; done | grep -E '${LONG_LIVED_CREDENTIAL}' | head -20;
201
204
  echo "--- end";
202
205
  `;
203
206
 
207
+ // OpenCode 2's own login store: rows in the `credential` table of `opencode.db`. The control
208
+ // writes two the way OpenCode stores them: an OpenAI browser login, and a key for a provider of the
209
+ // user's own that OpenCode's catalog does not know, which no integration route lists and no key
210
+ // pattern matches. No route can create an OAuth record without a real login, so the rows are
211
+ // written with Node's built-in SQLite into the database the running OpenCode uses. The timeout
212
+ // waits for OpenCode's own lock instead of failing on it.
213
+ const DECOY_CREDENTIAL_ID = 'cred_decoy000000000000000000';
214
+ const DECOY_CUSTOM_CREDENTIAL_ID = 'cred_decoy000000000000000001';
215
+ const openDatabase = (databasePath, options = '{ timeout: 5000 }') => `
216
+ const { DatabaseSync } = require('node:sqlite');
217
+ const db = new DatabaseSync(${JSON.stringify(databasePath)}, ${options});
218
+ `;
219
+ const plantCredential = (databasePath) => `${openDatabase(databasePath)}
220
+ const now = Date.now();
221
+ const insert = db.prepare('INSERT INTO credential (id, integration_id, label, value, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)');
222
+ insert.run('${DECOY_CREDENTIAL_ID}', 'openai', 'default', JSON.stringify({ type: 'oauth', methodID: 'chatgpt-browser', refresh: 'decoy-refresh-value', access: 'decoy-access-value', expires: now + 3600000 }), 1, now, now);
223
+ insert.run('${DECOY_CUSTOM_CREDENTIAL_ID}', 'acme-llm', 'default', JSON.stringify({ type: 'key', key: 'acme_live_decoy' }), 1, now, now);
224
+ db.close();
225
+ `;
226
+ const removeCredential = (databasePath) => `${openDatabase(databasePath)}
227
+ const { changes } = db.prepare('DELETE FROM credential WHERE id IN (?, ?)').run('${DECOY_CREDENTIAL_ID}', '${DECOY_CUSTOM_CREDENTIAL_ID}');
228
+ db.close();
229
+ console.log('removed:' + changes);
230
+ `;
231
+ // Every stored login, by integration and kind, read straight from the table. OpenCode lists only
232
+ // the integrations its catalog knows, so this is the check that sees a custom provider's key.
233
+ const readCredentials = (databasePath) => `${openDatabase(databasePath, '{ readOnly: true, timeout: 5000 }')}
234
+ const rows = db.prepare("SELECT integration_id AS integration, json_extract(value, '$.type') AS type FROM credential ORDER BY id").all();
235
+ db.close();
236
+ console.log(JSON.stringify(rows.map((row) => ({ integration: row.integration, type: row.type }))));
237
+ `;
238
+
204
239
  const sleep = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
205
240
 
206
241
  const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
@@ -458,7 +493,6 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
458
493
  // Positive control: the metadata is real and complete enough to show the environment.
459
494
  expect(metadata).toContain('OPENCODE_DISABLE_AUTOUPDATE=1');
460
495
  expect(metadata).not.toContain(token);
461
- expect(metadata).not.toContain('OPENCODE_AUTH_CONTENT');
462
496
  });
463
497
 
464
498
  it('cannot steer the requests of the host through its own ~/.curlrc', async () => {
@@ -796,6 +830,37 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
796
830
  expect(JSON.stringify(journal)).not.toContain(MODEL_KEY);
797
831
  }, 120_000);
798
832
 
833
+ // The obligation of STAGES.md on the first real credential behind the window: on a Linux
834
+ // Docker host every local process can reach the outer bridge, so the corridor and the
835
+ // window listen on the gatekeeper's inner address only. A neighbour on the outer network
836
+ // stands in for such a process here; the same measurement from a Linux host itself is in
837
+ // the module documentation.
838
+ it('cannot be reached on its outer address: the corridor and the window listen on the inner network only', async () => {
839
+ const addresses = await host.gatekeeperAddresses(spec.id);
840
+ expect(addresses.outer).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
841
+ const fromOutside = async (address, port) => (await host.probeFromOuterNetwork(spec.id, tcpProbe(address, port))).stdout.trim();
842
+ // Positive control: the neighbour's networking works, it reaches the stand-in upstream beside it.
843
+ expect(await fromOutside('upstream', 9000)).toMatch(/^connected:/);
844
+ for (const port of [GATEKEEPER_CORRIDOR_PORT, GATEKEEPER_WINDOW_PORT, GATEKEEPER_CONTROL_PORT]) {
845
+ expect(await fromOutside(addresses.outer, port), `outer address, port ${port}`).toBe('failed:ECONNREFUSED');
846
+ }
847
+ // And the space still reaches both by the gatekeeper's name, which resolves to the inner address.
848
+ expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_CORRIDOR_PORT))).toBe(`connected:${addresses.inner}`);
849
+ expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_WINDOW_PORT))).toBe(`connected:${addresses.inner}`);
850
+ }, 180_000);
851
+
852
+ it('gets no credential from the window for an opened domain, and none of its own reaches the upstream', async () => {
853
+ await channel().addGrant(spec.id, { id: 'open-registry', upstream: upstream.url });
854
+ const answer = await probe(windowProbe('/model/open-registry/-/package/left-pad', { 'x-api-key': 'sk-fake-inside', authorization: 'Bearer sk-fake-inside' }));
855
+ expect(answer).toMatch(/^200 /);
856
+ const seen = JSON.parse(answer.slice(4));
857
+ expect(seen.path).toBe('/v1/-/package/left-pad');
858
+ expect(seen.apiKey).toBe('none');
859
+ expect(seen.authorization).toBe('none');
860
+ expect(seen.headerNames.filter((name) => /key|auth|token/i.test(name))).toEqual([]);
861
+ expect(answer).not.toContain(MODEL_KEY);
862
+ }, 120_000);
863
+
799
864
  it('reaches a real upstream over TLS, with no credential of anyone\'s', async () => {
800
865
  publicAddress();
801
866
  await channel().addGrant(spec.id, { id: 'tls', upstream: 'https://example.com/', header: 'authorization', secret: 'not-a-credential' });
@@ -877,35 +942,74 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
877
942
  // ever inside a space. Stage 2 puts none there, and this fails the day something does.
878
943
  it('holds no long-lived credential of the user\'s, anywhere the space can read', async () => {
879
944
  const search = () => shell(credentialSearch(spec.id));
945
+ const server = createSpaceServerChannel({ exec: place.exec });
946
+ const login = await server.request(spec.id, { method: 'POST', path: '/auth/session', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: await server.readToken(spec.id) }) });
947
+ expect(login.status).toBe(200);
948
+ const cookie = login.headers['set-cookie'][0].split(';')[0];
949
+ // What OpenCode itself holds: every connection of every integration, a stored login or a
950
+ // provider key it found in its own environment under one of its catalog's names.
951
+ const connections = async () => {
952
+ const answer = await server.request(spec.id, { path: '/api/integration', headers: { Cookie: cookie } });
953
+ expect(answer.status).toBe(200);
954
+ const integrations = JSON.parse(answer.body).data;
955
+ expect(integrations.length).toBeGreaterThan(0);
956
+ return integrations.flatMap((integration) => integration.connections.map((connection) => ({ integration: integration.id, ...connection })));
957
+ };
958
+ const database = `${SPACE_HOME}/.local/share/opencode/opencode.db`;
959
+ const storedCredentials = async () => {
960
+ const answer = await inside(['node', '--no-warnings', '-e', readCredentials(database)]);
961
+ expect(answer.code, answer.stderr).toBe(0);
962
+ return JSON.parse(answer.stdout);
963
+ };
880
964
  const decoyRecord = `${SPACE_HOME}/.local/share/opencode/auth.json`;
881
965
  const decoy = 'sk-decoy00000000000000000000';
882
966
 
967
+ // The clean search runs first. SQLite keeps a deleted row's bytes in its write-ahead log,
968
+ // so after the control below the database would hold the decoy's text for good.
969
+ const found = (await search()).stdout;
970
+ // Positive control of its own: the search ran and looked in all three places.
971
+ expect(found).toContain('--- files');
972
+ expect(found).toContain('--- environments');
973
+ expect(found).toContain('--- end');
974
+ expect(found).toContain('auth-record: absent');
975
+ expect(found.slice(found.indexOf('--- files'))).toBe('--- files\n--- environments\n--- end\n');
976
+ expect(await connections()).toEqual([]);
977
+ expect(await storedCredentials()).toEqual([]);
978
+
883
979
  // Positive control: the same search, with a credential of each shape planted where one
884
- // would live. It must find all three, or a clean answer below would mean nothing.
980
+ // would live. The oauth refresh record goes only into OpenCode's database, so a search
981
+ // that skips binary files fails here. The legacy auth.json, which OpenCode 2 imports once,
982
+ // holds nothing and only shows that its path is checked.
885
983
  const planted = await shell([
886
984
  `mkdir -p "$(dirname ${decoyRecord})"`,
887
- `printf '{"openai":{"type":"oauth","refresh":"decoy-refresh-value"}}' > ${decoyRecord}`,
985
+ `printf '{}' > ${decoyRecord}`,
888
986
  `printf -- '-----BEGIN OPENSSH PRIVATE KEY-----' > "$HOME/decoy-key"`,
889
987
  `DECOY_TOKEN=${decoy} sleep 30 & echo "decoy-pid:$!"`,
890
988
  ].join('; '));
891
989
  const decoyPid = /decoy-pid:(\d+)/.exec(planted.stdout)?.[1];
990
+ let removal;
892
991
  try {
992
+ const row = await inside(['node', '--no-warnings', '-e', plantCredential(database)]);
993
+ expect(row.code, row.stderr).toBe(0);
994
+ // OpenCode reads the row as a login, so this is where OpenCode 2 keeps them.
995
+ expect(await connections()).toEqual([{ integration: 'openai', type: 'credential', id: DECOY_CREDENTIAL_ID, label: 'default', method: 'oauth' }]);
996
+ // The custom provider's key is invisible to that listing, and the table read sees it.
997
+ expect(await storedCredentials()).toEqual([{ integration: 'openai', type: 'oauth' }, { integration: 'acme-llm', type: 'key' }]);
998
+
893
999
  const control = (await search()).stdout;
894
1000
  expect(control).toContain('auth-record: present');
895
- expect(control).toMatch(/"refresh"\s*:\s*"/);
1001
+ expect(control).toMatch(/opencode\.db(-wal)?:"refresh"\s*:\s*"/);
896
1002
  expect(control).toContain('BEGIN OPENSSH PRIVATE KEY');
897
1003
  expect(control).toContain(decoy);
898
1004
  } finally {
1005
+ removal = await inside(['node', '--no-warnings', '-e', removeCredential(database)]);
899
1006
  await shell(`rm -f ${decoyRecord} "$HOME/decoy-key"; ${decoyPid ? `kill ${decoyPid} 2>/dev/null` : 'true'}; true`);
900
1007
  }
901
-
902
- const found = (await search()).stdout;
903
- // Positive control of its own: the search ran and looked in all three places.
904
- expect(found).toContain('--- files');
905
- expect(found).toContain('--- environments');
906
- expect(found).toContain('--- end');
907
- expect(found).toContain('auth-record: absent');
908
- expect(found.slice(found.indexOf('--- files'))).toBe('--- files\n--- environments\n--- end\n');
1008
+ // Checked after the finally, so a failed control above is the error that gets reported.
1009
+ expect(removal.code, removal.stderr).toBe(0);
1010
+ expect(removal.stdout).toBe('removed:2\n');
1011
+ expect(await connections()).toEqual([]);
1012
+ expect(await storedCredentials()).toEqual([]);
909
1013
  }, 180_000);
910
1014
 
911
1015
  // One space is not enough to say anything about two. This makes a second one and looks
@@ -96,7 +96,9 @@ export function hardenedContainerEntry({
96
96
  PortBindings: {},
97
97
  },
98
98
  Mounts: mountList.map(volumeMountEntry),
99
- NetworkSettings: { Networks: { [network]: aliases.length > 0 ? { Aliases: aliases } : {} } },
99
+ // Like the real engine once the container runs: an address on its network. The fake gives it
100
+ // from the start, and the place's tests hold it to reading the address after the start.
101
+ NetworkSettings: { Networks: { [network]: { IPAddress: '172.19.0.2', ...(aliases.length > 0 ? { Aliases: aliases } : {}) } } },
100
102
  };
101
103
  }
102
104
 
@@ -0,0 +1,197 @@
1
+ // Grants on a real space, made through the journey on a real Docker daemon. Runs only with
2
+ // OPENCHAMBER_TEST_DOCKER=1. A real key goes behind the window of a space made through the
3
+ // journey's own routes; a stand-in provider on the space's outer network answers with the hash
4
+ // of the key it saw and never with the key; and the key is then looked for everywhere the host
5
+ // and the space keep things: the record on disk, the metadata of both containers, the space's
6
+ // environment, processes and files. OpenCode inside is asked for a turn against that provider,
7
+ // which proves that the provider configuration the host wrote sends it through the window.
8
+
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import http from 'node:http';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import express from 'express';
16
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
17
+
18
+ import { createGlobalMessageStreamHub } from '../../event-stream/global-hub.js';
19
+ import { registerOpenCodeProxy } from '../../opencode/proxy.js';
20
+ import { runCommand } from '../run-command.js';
21
+ import { createSpacesHost } from '../host.js';
22
+ import { SPACE_OPENCODE_CONFIG_PATH } from '../layout.js';
23
+ import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
24
+
25
+ const CREATE_TIMEOUT_MS = 25 * 60_000;
26
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
27
+ const until = async (check, timeoutMs = 10_000) => {
28
+ const deadline = Date.now() + timeoutMs;
29
+ while (!(await check()) && Date.now() < deadline) await sleep(250);
30
+ return check();
31
+ };
32
+ const listen = (app) => new Promise((resolve) => {
33
+ const server = http.createServer(app);
34
+ server.listen(0, '127.0.0.1', () => resolve(server));
35
+ });
36
+ const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
37
+ const temporary = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
38
+
39
+ describe.skipIf(!LIVE_DOCKER_ENABLED)('grants on a real space: docker (live)', () => {
40
+ // Two real keys, made here. Neither ever enters the space; the stand-in provider sees them.
41
+ const TYPED_KEY = `sk-ant-live-${crypto.randomBytes(24).toString('hex')}`;
42
+ const ENV_KEY = `sk-oai-live-${crypto.randomBytes(24).toString('hex')}`;
43
+ let place;
44
+ let dispose = async () => {};
45
+ let liveHost;
46
+ let dataDir;
47
+ let project;
48
+ let host;
49
+ let hub;
50
+ let hostOpenCode;
51
+ let server;
52
+ let spaceId;
53
+ let upstream = { seen: async () => [], stop: async () => {} };
54
+ let session = null;
55
+ const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
56
+ const inside = async (argv) => place.exec(spaceId, argv, { timeoutMs: 60_000 });
57
+ const shell = (script) => inside(['sh', '-c', script]);
58
+ const prefixed = (suffix, init) => fetch(url(`/api/spaces/${spaceId}${suffix}`), init);
59
+ const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
60
+ const listed = async () => (await host.journey.listSpaces({ access: true })).find((space) => space.id === spaceId);
61
+ const recordText = () => fs.readFileSync(path.join(dataDir, 'spaces', 'records', `${spaceId}.json`), 'utf8');
62
+
63
+ beforeAll(async () => {
64
+ ({ place, dispose, host: liveHost } = createLiveDockerPlace());
65
+ dataDir = temporary('openchamber-grants-live-');
66
+ // The registered project the space is made for: a small repository of its own.
67
+ project = temporary('openchamber-grants-project-');
68
+ const git = (...args) => runCommand('git', ['-C', project, ...args]);
69
+ await git('init', '-q', '.');
70
+ fs.writeFileSync(path.join(project, 'README.md'), 'A project for the grants live pass.\n');
71
+ await git('add', '.');
72
+ await git('-c', 'user.email=me@example.invalid', '-c', 'user.name=Me', 'commit', '-q', '-m', 'init');
73
+
74
+ host = createSpacesHost({
75
+ dataDir,
76
+ place,
77
+ listProjectDirectories: async () => [project],
78
+ hostEnvironment: { ...process.env, OPENAI_API_KEY: ENV_KEY },
79
+ logger: { warn: () => {} },
80
+ });
81
+ // The host's own OpenCode: nothing but an event stream that stays open.
82
+ const hostApp = express();
83
+ hostApp.get('/api/session', (_req, res) => res.json({ data: [], cursor: {} }));
84
+ hostApp.get('/api/event', (_req, res) => { res.setHeader('content-type', 'text/event-stream'); res.flushHeaders(); });
85
+ hostOpenCode = await listen(hostApp);
86
+ const hostPort = hostOpenCode.address().port;
87
+ hub = createGlobalMessageStreamHub({ buildOpenCodeUrl: (p) => `http://127.0.0.1:${hostPort}${p}`, getOpenCodeAuthHeaders: () => ({}), deltaCoalesceWindowMs: 0 });
88
+ const app = express();
89
+ app.use(host.middleware);
90
+ registerOpenCodeProxy(app, {
91
+ fs: {}, os: {}, path, OPEN_CODE_READY_GRACE_MS: 0,
92
+ getRuntime: () => ({ openCodePort: hostPort, isOpenCodeReady: true, openCodeNotReadySince: 0, isRestartingOpenCode: false }),
93
+ getOpenCodeAuthHeaders: () => ({}),
94
+ buildOpenCodeUrl: (p) => `http://127.0.0.1:${hostPort}${p}`,
95
+ ensureOpenCodeApiPrefix: () => {},
96
+ mergeSpaceSessionList: (payload) => host.mergeSessionList(payload),
97
+ spaceEventHub: hub,
98
+ });
99
+ server = await listen(app);
100
+ await host.startEvents(hub);
101
+
102
+ // The space, through the journey, as the create dialog will make it.
103
+ const answer = await host.journey.createSpace({ projectDirectory: project, name: 'Grants live', start: 'clean', network: { mode: 'allowlist', domains: [] } });
104
+ spaceId = answer.id;
105
+ expect(await until(async () => (await listed())?.state === 'running' && (await listed())?.step === null, CREATE_TIMEOUT_MS)).toBe(true);
106
+ upstream = await liveHost.startWindowUpstream(spaceId);
107
+ }, CREATE_TIMEOUT_MS);
108
+
109
+ afterAll(async () => {
110
+ host?.close();
111
+ hub?.stop();
112
+ for (const running of [server, hostOpenCode]) {
113
+ await new Promise((resolve) => { if (running) { running.closeAllConnections?.(); running.close(() => resolve()); } else resolve(); });
114
+ }
115
+ await upstream.stop();
116
+ await dispose();
117
+ for (const folder of [dataDir, project]) if (folder) fs.rmSync(folder, { recursive: true, force: true });
118
+ });
119
+
120
+ it('gives a typed key to the gatekeeper and keeps it out of the record, the containers and the space', async () => {
121
+ const granted = await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'anthropic', upstream: upstream.url, secret: { kind: 'typed', value: TYPED_KEY } });
122
+ expect(granted.grant).toMatchObject({ id: 'anthropic', header: 'x-api-key', source: { kind: 'typed' }, url: 'http://gatekeeper:8080/model/anthropic' });
123
+ expect(JSON.stringify(granted)).not.toContain(TYPED_KEY);
124
+
125
+ // The window carries the real key: a request from inside reaches the stand-in with its hash.
126
+ const through = await shell('curl -s -H "x-api-key: space-window" http://gatekeeper:8080/model/anthropic/messages');
127
+ expect(through.code, through.stderr).toBe(0);
128
+ expect(JSON.parse(through.stdout)).toMatchObject({ path: '/v1/messages', apiKey: sha256(TYPED_KEY), authorization: 'none' });
129
+
130
+ // Not on the host's disk.
131
+ expect(recordText()).not.toContain(TYPED_KEY);
132
+ expect(JSON.parse(recordText()).grants).toEqual([{ kind: 'model', id: 'anthropic', provider: 'anthropic', upstream: upstream.url, header: 'x-api-key', source: { kind: 'typed' } }]);
133
+ // Not in what the runtime records about either container.
134
+ expect(await liveHost.spaceMetadata(spaceId)).not.toContain(TYPED_KEY);
135
+ expect(await liveHost.gatekeeperMetadata(spaceId)).not.toContain(TYPED_KEY);
136
+ // Not anywhere the space can look. The space prints what it has; the host searches it.
137
+ const insideTheSpace = await shell(`env; ps auxeww; find /home/space /tmp /spaces/${spaceId} -type f -size -1M -exec cat {} + 2>/dev/null; true`);
138
+ expect(insideTheSpace.stdout.length).toBeGreaterThan(0);
139
+ expect(insideTheSpace.stdout).not.toContain(TYPED_KEY);
140
+ // What the space does have: the configuration that points OpenCode at the window.
141
+ const config = JSON.parse((await inside(['cat', SPACE_OPENCODE_CONFIG_PATH])).stdout);
142
+ expect(config.provider.anthropic.options).toEqual({ baseURL: 'http://gatekeeper:8080/model/anthropic', apiKey: 'space-window' });
143
+ expect((await listed())).toMatchObject({ access: 'granted', needsAccess: [], grants: [expect.objectContaining({ id: 'anthropic' })] });
144
+ }, 120_000);
145
+
146
+ it('sends a turn of OpenCode inside through the window, where the stand-in sees the real key', async () => {
147
+ const directory = (await listed()).directory;
148
+ const created = await prefixed('/session', json({ location: { directory }, title: 'Through the window' }));
149
+ expect(created.status, await created.clone().text()).toBe(200);
150
+ session = (await created.json()).data;
151
+ const model = await prefixed(`/session/${session.id}/model`, { ...json({ model: { id: 'claude-sonnet-4-5', providerID: 'anthropic' } }), headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) } });
152
+ expect(model.status, await model.clone().text()).toBeLessThan(300);
153
+ const prompt = await prefixed(`/session/${session.id}/prompt`, { ...json({ id: `msg_${crypto.randomBytes(8).toString('hex')}`, text: 'Say hello.' }), headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) } });
154
+ expect(prompt.status, await prompt.clone().text()).toBeLessThan(300);
155
+
156
+ // A POST: the curl of the test above was a GET with the same key, and this must be OpenCode's own request.
157
+ const fromOpenCode = (entry) => entry.method === 'POST' && entry.apiKey === sha256(TYPED_KEY) && entry.path.startsWith('/v1/messages');
158
+ expect(await until(async () => (await upstream.seen()).some(fromOpenCode), 120_000)).toBe(true);
159
+ expect((await upstream.seen()).find(fromOpenCode)).toMatchObject({ authorization: 'none' });
160
+ expect(JSON.stringify(await upstream.seen())).not.toContain(TYPED_KEY);
161
+ }, 180_000);
162
+
163
+ it('measures whether OpenCode inside takes a provider granted after its instance started', async () => {
164
+ // A second provider, from the host's environment this time, granted while the instance of
165
+ // the space's project is already running. Whether that instance re-reads its configuration
166
+ // without a restart is what this measures; the outcome is reported, not asserted, because
167
+ // either way the key is out of the space and the window is the only way to the provider.
168
+ const granted = await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'openai', upstream: upstream.url, secret: { kind: 'env', name: 'OPENAI_API_KEY' } });
169
+ expect(granted.grant).toMatchObject({ id: 'openai', header: 'authorization', source: { kind: 'env', name: 'OPENAI_API_KEY' } });
170
+ expect(recordText()).not.toContain(ENV_KEY);
171
+ const directory = (await listed()).directory;
172
+ const headers = { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) };
173
+ await prefixed(`/session/${session.id}/model`, { ...json({ model: { id: 'gpt-4.1', providerID: 'openai' } }), headers });
174
+ await prefixed(`/session/${session.id}/prompt`, { ...json({ id: `msg_${crypto.randomBytes(8).toString('hex')}`, text: 'Say hello again.' }), headers });
175
+ const arrived = await until(async () => (await upstream.seen()).some((seen) => seen.authorization === sha256(`Bearer ${ENV_KEY}`)), 60_000);
176
+ // stderr, because vitest keeps a passing test's console.log to itself.
177
+ process.stderr.write(`[grants live] a provider granted after the instance started ${arrived ? 'was used on the next turn without a restart' : 'was NOT used on the next turn: the instance keeps the configuration it started with'}\n`);
178
+ expect(JSON.stringify(await upstream.seen())).not.toContain(ENV_KEY);
179
+ }, 180_000);
180
+
181
+ it('says the grants again after a stop and a start: the environment key yes, the typed key no', async () => {
182
+ await host.journey.stopSpace(spaceId);
183
+ const started = await host.journey.startSpace(spaceId);
184
+ expect(started).toMatchObject({ state: 'running', networkRestored: true, grantsRestored: ['openai'], needsAccess: ['anthropic'] });
185
+ expect(await listed()).toMatchObject({ access: 'needs_access', needsAccess: ['anthropic'] });
186
+ // The environment key works again at once; the typed one is gone with the gatekeeper's memory.
187
+ const bearer = await shell('curl -s http://gatekeeper:8080/model/openai/responses');
188
+ expect(JSON.parse(bearer.stdout)).toMatchObject({ path: '/v1/responses', authorization: sha256(`Bearer ${ENV_KEY}`) });
189
+ const missing = await shell('curl -s -o /dev/null -w "%{http_code}" http://gatekeeper:8080/model/anthropic/messages');
190
+ expect(missing.stdout).toBe('403');
191
+ // Granting once more brings it back.
192
+ await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'anthropic', upstream: upstream.url, secret: { kind: 'typed', value: TYPED_KEY } });
193
+ expect(await listed()).toMatchObject({ access: 'granted', needsAccess: [] });
194
+ expect(recordText()).not.toContain(TYPED_KEY);
195
+ expect(recordText()).not.toContain(ENV_KEY);
196
+ }, 300_000);
197
+ });
@@ -5,6 +5,8 @@
5
5
  // one container would pass nothing here.
6
6
 
7
7
  import crypto from 'node:crypto';
8
+ import http from 'node:http';
9
+ import net from 'node:net';
8
10
 
9
11
  import { SpaceError } from '../errors.js';
10
12
  import { ROLE_GATEKEEPER, ROLE_SPACE, requireSpaceId } from '../labels.js';
@@ -57,6 +59,14 @@ export function createMemoryPlace({ id = 'memory' } = {}) {
57
59
  // A container has a hostname of its own, as a real one does, so a caller can tell them apart.
58
60
  containers.set(`${spaceId}:${target}`, { hostname: crypto.randomBytes(6).toString('hex') });
59
61
  }
62
+ // The server inside, for `connect`: a loopback listener of this space's own that answers
63
+ // `/health` the way the real one does, with the space id so a caller can tell spaces apart.
64
+ const server = http.createServer((request, response) => {
65
+ response.setHeader('content-type', 'application/json');
66
+ response.end(JSON.stringify({ status: 'ok', isOpenCodeReady: true, spaceId, path: request.url }));
67
+ });
68
+ await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
69
+ containers.get(`${spaceId}:${ROLE_SPACE}`).server = server;
60
70
  },
61
71
  list: async () => Array.from(spaces.values(), (space) => ({ ...space })),
62
72
  exec: async (spaceId, argv, options = {}) => {
@@ -84,12 +94,25 @@ export function createMemoryPlace({ id = 'memory' } = {}) {
84
94
  }
85
95
  return [process.execPath, '-e', CONTAINER_PROGRAM, hostname];
86
96
  },
97
+ // Added in stage 4a. A channel to the server inside, refused like `execArgv` when the space
98
+ // is stopped or gone: a plain socket to this space's own listener.
99
+ connect: async (spaceId) => {
100
+ const { server } = requireContainer(spaceId);
101
+ if (requireSpace(spaceId).state !== 'running') {
102
+ throw new SpaceError('space_not_running', `Space ${spaceId} is stopped`);
103
+ }
104
+ return net.connect({ host: '127.0.0.1', port: server.address().port });
105
+ },
87
106
  stop: async (spaceId) => { requireSpace(spaceId).state = 'exited'; },
88
107
  start: async (spaceId) => { requireSpace(spaceId).state = 'running'; },
89
108
  remove: async (spaceId) => {
90
109
  const removed = [];
91
110
  for (const target of TARGETS) {
92
- if (containers.delete(`${requireSpaceId(spaceId)}:${target}`)) removed.push({ kind: 'container', name: `${spaceId}-${target}` });
111
+ const container = containers.get(`${requireSpaceId(spaceId)}:${target}`);
112
+ if (!container) continue;
113
+ container.server?.close();
114
+ containers.delete(`${spaceId}:${target}`);
115
+ removed.push({ kind: 'container', name: `${spaceId}-${target}` });
93
116
  }
94
117
  if (spaces.delete(spaceId)) removed.push({ kind: 'space', name: spaceId });
95
118
  return { removed, failed: [] };
@@ -1,4 +1,4 @@
1
- export const REQUIRED_PLACE_METHODS = ['check', 'create', 'list', 'exec', 'execArgv', 'stop', 'start', 'remove', 'verify'];
1
+ export const REQUIRED_PLACE_METHODS = ['check', 'create', 'list', 'exec', 'execArgv', 'connect', 'stop', 'start', 'remove', 'verify'];
2
2
 
3
3
  export function createPlaceRegistry(initialPlaces = []) {
4
4
  const places = new Map();