@vantran-se/openchamber-web 1.24.4 → 2.0.1

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 (367) hide show
  1. package/README.md +2 -8
  2. package/bin/lib/commands-connect-url.js +3 -8
  3. package/bin/lib/commands-tunnel.js +3 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
  5. package/dist/assets/DiffView-CjxUovbM.js +4 -0
  6. package/dist/assets/FilesView-DFWD0RQR.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-3sGwqexh.js} +1 -1
  11. package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
  14. package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
  17. package/dist/assets/PlanView-BCk_VobP.js +5 -0
  18. package/dist/assets/PluginPane-BPP1dbck.js +3 -0
  19. package/dist/assets/SettingsView-kMmrpn43.js +29 -0
  20. package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
  22. package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
  29. package/dist/assets/de-CndEg67T.js +10 -0
  30. package/dist/assets/es-DglvPlaC.js +16 -0
  31. package/dist/assets/fr-DejPCpbS.js +14 -0
  32. package/dist/assets/index-C2r64QfD.js +1 -0
  33. package/dist/assets/index-CLcM8HKx.css +1 -0
  34. package/dist/assets/index-CvLgnuNI.js +41 -0
  35. package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-B-mGgeKl.js} +1 -1
  37. package/dist/assets/ja-CVpQawCd.js +16 -0
  38. package/dist/assets/ko-Bw446h9j.js +16 -0
  39. package/dist/assets/main-Bz4WWXbr.js +2 -0
  40. package/dist/assets/main-oreqDsOV.js +896 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-D2e5sp9y.js} +4 -4
  42. package/dist/assets/miniChat-D1v2gsBI.js +2 -0
  43. package/dist/assets/mobile-DBj3lCf2.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-BpeUOV91.js} +3 -3
  45. package/dist/assets/pl-BYNlPdhm.js +16 -0
  46. package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
  48. package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
  50. package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
  53. package/dist/assets/tr-DzmbxLy-.js +7 -0
  54. package/dist/assets/uk-C7vQnwro.js +16 -0
  55. package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
  59. package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
  60. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
  61. package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
  62. package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
  63. package/dist/assets/{web-components-DxX8bZ1o.js → web-components-B6tKQyJN.js} +2 -2
  64. package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
  65. package/dist/assets/zh-TW-DtII5bDb.js +16 -0
  66. package/dist/index.html +13 -7
  67. package/dist/mini-chat.html +5 -5
  68. package/dist/mobile.html +5 -5
  69. package/package.json +6 -5
  70. package/server/index.d.ts +1 -0
  71. package/server/index.js +246 -91
  72. package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
  73. package/server/lib/agent-tool/runtime.js +135 -54
  74. package/server/lib/agent-tool/runtime.test.js +155 -87
  75. package/server/lib/browser-control/DOCUMENTATION.md +37 -2
  76. package/server/lib/browser-control/provider.js +217 -0
  77. package/server/lib/browser-control/provider.test.js +251 -0
  78. package/server/lib/context-obligatory/runtime.js +61 -50
  79. package/server/lib/context-obligatory/runtime.test.js +62 -162
  80. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  81. package/server/lib/event-stream/delta-coalescer.js +0 -0
  82. package/server/lib/event-stream/delta-coalescer.test.js +68 -57
  83. package/server/lib/event-stream/directory-ws-bridge.js +1 -1
  84. package/server/lib/event-stream/global-hub.js +10 -1
  85. package/server/lib/event-stream/global-hub.test.js +27 -26
  86. package/server/lib/event-stream/index.js +6 -0
  87. package/server/lib/event-stream/protocol.js +26 -19
  88. package/server/lib/event-stream/protocol.test.js +18 -7
  89. package/server/lib/event-stream/rebind.test.js +5 -5
  90. package/server/lib/event-stream/runtime.test.js +17 -15
  91. package/server/lib/event-stream/translate-v2.js +313 -0
  92. package/server/lib/event-stream/translate-v2.test.js +209 -0
  93. package/server/lib/event-stream/upstream-reader.test.js +16 -16
  94. package/server/lib/fs/DOCUMENTATION.md +4 -2
  95. package/server/lib/fs/byte-range.js +35 -0
  96. package/server/lib/fs/byte-range.test.js +31 -0
  97. package/server/lib/fs/routes.js +47 -42
  98. package/server/lib/fs/routes.test.js +123 -18
  99. package/server/lib/git/service.js +12 -0
  100. package/server/lib/github/DOCUMENTATION.md +1 -1
  101. package/server/lib/github/pr-status.js +22 -2
  102. package/server/lib/github/pr-status.test.js +27 -14
  103. package/server/lib/guests/DOCUMENTATION.md +13 -0
  104. package/server/lib/guests/catalog.js +12 -0
  105. package/server/lib/guests/catalog.test.js +33 -0
  106. package/server/lib/guests/routes.js +10 -1
  107. package/server/lib/guests/service.js +192 -14
  108. package/server/lib/guests/service.test.js +201 -1
  109. package/server/lib/guests/surface.js +630 -0
  110. package/server/lib/guests/surface.test.js +486 -0
  111. package/server/lib/markdown-image-grants/routes.js +18 -5
  112. package/server/lib/markdown-image-grants/routes.test.js +9 -6
  113. package/server/lib/message-queue/DOCUMENTATION.md +33 -22
  114. package/server/lib/message-queue/runtime.js +155 -100
  115. package/server/lib/message-queue/runtime.test.js +168 -82
  116. package/server/lib/notifications/DOCUMENTATION.md +28 -0
  117. package/server/lib/notifications/control-stream.http.test.js +68 -0
  118. package/server/lib/notifications/emitter-runtime.js +15 -0
  119. package/server/lib/notifications/emitter-runtime.test.js +32 -0
  120. package/server/lib/notifications/push-runtime.js +5 -1
  121. package/server/lib/notifications/push-runtime.test.js +2 -2
  122. package/server/lib/notifications/routes.js +1 -1
  123. package/server/lib/notifications/runtime.js +115 -26
  124. package/server/lib/notifications/template-runtime.js +13 -15
  125. package/server/lib/notifications/template-runtime.test.js +16 -9
  126. package/server/lib/notifications/trigger-runtime.test.js +187 -0
  127. package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
  128. package/server/lib/openchamber-control/actions.js +3 -2
  129. package/server/lib/openchamber-control/error.js +1 -1
  130. package/server/lib/openchamber-control/file-open.js +59 -0
  131. package/server/lib/openchamber-control/file-open.test.js +57 -0
  132. package/server/lib/openchamber-control/routes.test.js +12 -0
  133. package/server/lib/openchamber-control/service.js +102 -49
  134. package/server/lib/openchamber-control/service.test.js +84 -44
  135. package/server/lib/openchamber-sessions/archive-store.js +224 -0
  136. package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
  137. package/server/lib/openchamber-sessions/opencode-client.js +22 -0
  138. package/server/lib/openchamber-sessions/routes.js +398 -328
  139. package/server/lib/openchamber-sessions/routes.test.js +777 -740
  140. package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
  141. package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
  142. package/server/lib/opencode/DOCUMENTATION.md +449 -29
  143. package/server/lib/opencode/agents.js +233 -327
  144. package/server/lib/opencode/auth.js +41 -44
  145. package/server/lib/opencode/auth.test.js +92 -0
  146. package/server/lib/opencode/cli-upgrade.d.ts +4 -0
  147. package/server/lib/opencode/cli-upgrade.js +18 -0
  148. package/server/lib/opencode/cli-upgrade.test.js +36 -0
  149. package/server/lib/opencode/commands.js +164 -145
  150. package/server/lib/opencode/compatibility.d.ts +16 -0
  151. package/server/lib/opencode/compatibility.js +88 -0
  152. package/server/lib/opencode/compatibility.test.js +62 -0
  153. package/server/lib/opencode/config-entity-routes.js +58 -29
  154. package/server/lib/opencode/config-entity-v2.test.js +290 -0
  155. package/server/lib/opencode/config-mutation-response.js +17 -7
  156. package/server/lib/opencode/config-mutation-response.test.js +4 -7
  157. package/server/lib/opencode/config-paths.test.js +2 -2
  158. package/server/lib/opencode/config-v2.d.ts +183 -0
  159. package/server/lib/opencode/config-v2.js +801 -0
  160. package/server/lib/opencode/config-v2.test.js +466 -0
  161. package/server/lib/opencode/credential-db.js +144 -0
  162. package/server/lib/opencode/credential-db.test.js +104 -0
  163. package/server/lib/opencode/env-runtime.js +48 -9
  164. package/server/lib/opencode/env-runtime.test.js +45 -0
  165. package/server/lib/opencode/feature-routes-runtime.js +15 -5
  166. package/server/lib/opencode/lifecycle-process.test.js +7 -4
  167. package/server/lib/opencode/lifecycle.js +85 -30
  168. package/server/lib/opencode/lifecycle.test.js +80 -8
  169. package/server/lib/opencode/login-shell-env.js +27 -0
  170. package/server/lib/opencode/managed-config-file.js +119 -0
  171. package/server/lib/opencode/managed-config-file.test.js +152 -0
  172. package/server/lib/opencode/managed-plugin-config.js +33 -13
  173. package/server/lib/opencode/mcp.js +43 -149
  174. package/server/lib/opencode/network-runtime.js +6 -7
  175. package/server/lib/opencode/openchamber-routes.test.js +2 -5
  176. package/server/lib/opencode/plugin-routes.js +3 -3
  177. package/server/lib/opencode/plugin-routes.test.js +6 -24
  178. package/server/lib/opencode/plugins.js +91 -40
  179. package/server/lib/opencode/plugins.test.js +28 -15
  180. package/server/lib/opencode/providers.js +63 -83
  181. package/server/lib/opencode/providers.test.js +128 -59
  182. package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
  183. package/server/lib/opencode/proxy.js +179 -129
  184. package/server/lib/opencode/pwa-manifest-routes.js +4 -2
  185. package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
  186. package/server/lib/opencode/response-envelope.js +14 -0
  187. package/server/lib/opencode/response-envelope.test.js +23 -0
  188. package/server/lib/opencode/routes-upgrade.test.js +73 -116
  189. package/server/lib/opencode/routes.js +121 -428
  190. package/server/lib/opencode/server-utils-runtime.js +10 -4
  191. package/server/lib/opencode/session-activity.js +78 -0
  192. package/server/lib/opencode/session-runtime.js +21 -10
  193. package/server/lib/opencode/session-runtime.test.js +9 -9
  194. package/server/lib/opencode/settings-helpers.js +9 -9
  195. package/server/lib/opencode/settings-helpers.test.js +7 -12
  196. package/server/lib/opencode/settings-registry.json +6 -8
  197. package/server/lib/opencode/settings-runtime.js +18 -1
  198. package/server/lib/opencode/settings-runtime.test.js +31 -2
  199. package/server/lib/opencode/shared.js +54 -22
  200. package/server/lib/opencode/shared.test.js +8 -4
  201. package/server/lib/opencode/shutdown-runtime.js +54 -7
  202. package/server/lib/opencode/shutdown-runtime.test.js +224 -30
  203. package/server/lib/opencode/skill-routes.js +23 -24
  204. package/server/lib/opencode/skills.js +7 -5
  205. package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
  206. package/server/lib/opencode/upgrade-capability.test.js +1 -1
  207. package/server/lib/opencode/v1-migration-topup.js +288 -0
  208. package/server/lib/opencode/v1-migration-topup.test.js +266 -0
  209. package/server/lib/opencode/v2-install.d.ts +8 -0
  210. package/server/lib/opencode/v2-install.js +158 -0
  211. package/server/lib/opencode/v2-install.test.js +124 -0
  212. package/server/lib/opencode/watcher.js +10 -3
  213. package/server/lib/opencode/watcher.test.js +17 -21
  214. package/server/lib/opencode/websearch-config.js +66 -0
  215. package/server/lib/package-manager.js +55 -68
  216. package/server/lib/package-manager.test.js +241 -75
  217. package/server/lib/permission-auto-accept/runtime.js +38 -22
  218. package/server/lib/permission-auto-accept/runtime.test.js +39 -17
  219. package/server/lib/quota/DOCUMENTATION.md +4 -0
  220. package/server/lib/quota/providers/xai.js +4 -4
  221. package/server/lib/relay/tunnel-host.js +4 -1
  222. package/server/lib/relay/tunnel-host.test.js +7 -0
  223. package/server/lib/routing/DOCUMENTATION.md +52 -21
  224. package/server/lib/routing/defaults.js +23 -6
  225. package/server/lib/routing/jev.js +17 -7
  226. package/server/lib/routing/routes.http.test.js +68 -33
  227. package/server/lib/routing/routes.js +65 -22
  228. package/server/lib/routing/runtime.js +145 -48
  229. package/server/lib/routing/runtime.test.js +79 -55
  230. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
  231. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
  232. package/server/lib/scheduled-tasks/runtime.js +81 -74
  233. package/server/lib/scheduled-tasks/runtime.test.js +53 -1
  234. package/server/lib/session-assist/DOCUMENTATION.md +8 -3
  235. package/server/lib/session-assist/context.js +122 -46
  236. package/server/lib/session-assist/context.test.js +117 -49
  237. package/server/lib/session-assist/runtime.js +52 -22
  238. package/server/lib/session-assist/runtime.test.js +145 -173
  239. package/server/lib/session-goal/DOCUMENTATION.md +23 -15
  240. package/server/lib/session-goal/create.js +9 -12
  241. package/server/lib/session-goal/create.test.js +27 -28
  242. package/server/lib/session-goal/runtime.js +164 -77
  243. package/server/lib/session-goal/runtime.test.js +271 -519
  244. package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
  245. package/server/lib/session-knowledge/runtime.js +59 -41
  246. package/server/lib/session-knowledge/runtime.test.js +90 -20
  247. package/server/lib/small-model/DOCUMENTATION.md +154 -208
  248. package/server/lib/small-model/client.js +113 -0
  249. package/server/lib/small-model/index.js +296 -158
  250. package/server/lib/small-model/index.test.js +423 -256
  251. package/server/lib/small-model/routes.js +1 -1
  252. package/server/lib/spaces/DOCUMENTATION.md +807 -0
  253. package/server/lib/spaces/code-in-bait.js +185 -0
  254. package/server/lib/spaces/code-in.js +641 -0
  255. package/server/lib/spaces/code-in.test.js +1048 -0
  256. package/server/lib/spaces/errors.js +8 -0
  257. package/server/lib/spaces/exec-http.js +94 -0
  258. package/server/lib/spaces/gatekeeper-channel.js +202 -0
  259. package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
  260. package/server/lib/spaces/gatekeeper-program.cjs +914 -0
  261. package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
  262. package/server/lib/spaces/hardening.js +457 -0
  263. package/server/lib/spaces/hardening.test.js +368 -0
  264. package/server/lib/spaces/host-git.js +127 -0
  265. package/server/lib/spaces/host-git.test.js +137 -0
  266. package/server/lib/spaces/labels.js +234 -0
  267. package/server/lib/spaces/labels.test.js +219 -0
  268. package/server/lib/spaces/layout.js +184 -0
  269. package/server/lib/spaces/layout.test.js +30 -0
  270. package/server/lib/spaces/manager.js +82 -0
  271. package/server/lib/spaces/manager.test.js +114 -0
  272. package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
  273. package/server/lib/spaces/places/contract-suite.js +156 -0
  274. package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
  275. package/server/lib/spaces/places/contract.test.js +6 -0
  276. package/server/lib/spaces/places/docker-engine.js +126 -0
  277. package/server/lib/spaces/places/docker-live-support.js +202 -0
  278. package/server/lib/spaces/places/docker-tools.js +186 -0
  279. package/server/lib/spaces/places/docker.js +587 -0
  280. package/server/lib/spaces/places/docker.test.js +1610 -0
  281. package/server/lib/spaces/places/escape-suite.js +1018 -0
  282. package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
  283. package/server/lib/spaces/places/fake-docker.js +375 -0
  284. package/server/lib/spaces/places/memory-place.js +103 -0
  285. package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
  286. package/server/lib/spaces/places/registry.js +43 -0
  287. package/server/lib/spaces/places/registry.test.js +50 -0
  288. package/server/lib/spaces/places/server.docker.live.test.js +225 -0
  289. package/server/lib/spaces/run-command.js +261 -0
  290. package/server/lib/spaces/run-command.test.js +396 -0
  291. package/server/lib/spaces/space-server.js +190 -0
  292. package/server/lib/spaces/space-server.test.js +335 -0
  293. package/server/lib/spaces/tools-filler.js +82 -0
  294. package/server/lib/spaces/tools-filler.test.js +148 -0
  295. package/server/lib/spaces/tools-pack.js +49 -0
  296. package/server/lib/spaces/tools-pack.test.js +57 -0
  297. package/server/lib/spaces/tools.js +138 -0
  298. package/server/lib/spaces/tools.test.js +153 -0
  299. package/server/lib/tts/openai-sdk.js +11 -0
  300. package/server/lib/tts/service.js +7 -5
  301. package/server/lib/tts/stt.js +3 -2
  302. package/server/lib/tunnels/DOCUMENTATION.md +2 -0
  303. package/server/lib/tunnels/routes.js +7 -0
  304. package/server/lib/tunnels/routes.test.js +55 -0
  305. package/server/lib/ui-auth/ui-auth.js +38 -2
  306. package/server/lib/ui-auth/ui-auth.test.js +42 -0
  307. package/server/lib/ui-auth/ui-passkeys.js +12 -10
  308. package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
  309. package/server/lib/walkthrough/pull-request.js +61 -8
  310. package/server/lib/walkthrough/pull-request.test.js +54 -1
  311. package/server/lib/walkthrough/routes.js +20 -0
  312. package/server/lib/walkthrough/routes.test.js +19 -0
  313. package/server/opencode-proxy.test.js +190 -166
  314. package/server/opencode-worktree-readiness.test.js +5 -4
  315. package/dist/assets/DiffView-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -0,0 +1,1018 @@
1
+ // Escape tests. Each one runs inside a real space as the attacker and passes only
2
+ // when the attempt fails. Every place runs this same suite.
3
+ //
4
+ // `setup` resolves `{ place, dispose, host }`. `host` is the test's own view from outside the space:
5
+ // runUnrestricted(script) runs a node script in a plain container with normal networking
6
+ // startHostListener() resolves { port, candidates, stop } for a TCP listener on the place's host,
7
+ // and `candidates`: every IPv4 address of that host plus the names that may lead to it
8
+ // createPlainInternalNetwork() resolves { run(script), remove } for an internal network WITHOUT the space's isolation
9
+ // logBytes(spaceId) resolves how many bytes of space output the host keeps
10
+ // spaceMetadata(spaceId) resolves, as text, everything the place's own records show about the space container
11
+ // gatekeeperMetadata(spaceId) the same for the gatekeeper container
12
+ // gatekeeperAddresses(spaceId) resolves { inner, outer }, the gatekeeper's own addresses
13
+ // startWindowUpstream(spaceId) a stand-in provider on the space's outer network. It answers with
14
+ // the sha256 of the credentials it saw, never with the credentials
15
+
16
+ import crypto from 'node:crypto';
17
+ import dns from 'node:dns/promises';
18
+ import net from 'node:net';
19
+ import os from 'node:os';
20
+
21
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
22
+
23
+ import { createGatekeeperChannel } from '../gatekeeper-channel.js';
24
+ import { createSpaceId, hashProjectDirectory } from '../labels.js';
25
+ import {
26
+ GATEKEEPER_ALIAS,
27
+ GATEKEEPER_CONTROL_PORT,
28
+ GATEKEEPER_CORRIDOR_PORT,
29
+ GATEKEEPER_WINDOW_PORT,
30
+ SPACE_HOME,
31
+ SPACE_SERVER_PORT,
32
+ TOOLS_BIN_PATH,
33
+ TOOLS_MARKER_PATH,
34
+ TOOLS_MOUNT_PATH,
35
+ } from '../layout.js';
36
+ import { createSpaceServerChannel } from '../space-server.js';
37
+
38
+ const CREATE_TIMEOUT_MS = 25 * 60_000;
39
+ // The server and OpenCode inside take about 370 MiB at rest. With 1 GiB the process that
40
+ // allocates without end is by far the largest when the limit is hit, so it is the one that gets killed.
41
+ const MEMORY_BYTES = 1024 * 1024 * 1024;
42
+ const LOG_CAP_BYTES = 11 * 1024 * 1024;
43
+
44
+ // Every probe prints one line, `connected:<ip>` or `failed:<reason>`, and exits 0 within ten seconds.
45
+ const PROBE_HEAD = "const done = (text) => { console.log(text); process.exit(0); };";
46
+
47
+ const tcpProbe = (host, port) => `${PROBE_HEAD}
48
+ const socket = require('node:net').connect({ host: '${host}', port: ${port} });
49
+ socket.setTimeout(4000, () => done('failed:timeout'));
50
+ socket.on('connect', () => done('connected:' + socket.remoteAddress));
51
+ socket.on('error', (error) => done('failed:' + error.code));
52
+ `;
53
+
54
+ // Tries every candidate at once and prints one JSON object: candidate to `connected:<ip>` or `failed:<reason>`.
55
+ const hostProbe = (candidates, port) => `
56
+ const net = require('node:net');
57
+ const attempt = (host) => new Promise((resolve) => {
58
+ const socket = net.connect({ host, port: ${port} });
59
+ const done = (text) => { socket.destroy(); resolve([host, text]); };
60
+ socket.setTimeout(4000, () => done('failed:timeout'));
61
+ socket.on('connect', () => done('connected:' + socket.remoteAddress));
62
+ socket.on('error', (error) => done('failed:' + error.code));
63
+ });
64
+ Promise.all(${JSON.stringify(candidates)}.map(attempt)).then((answers) => { console.log(JSON.stringify(Object.fromEntries(answers))); process.exit(0); });
65
+ `;
66
+
67
+ const DNS_PROBE = `${PROBE_HEAD}
68
+ setTimeout(() => done('failed:timeout'), 5000);
69
+ require('node:dns').promises.lookup('example.com').then(
70
+ (answer) => done('resolved:' + answer.address),
71
+ (error) => done('failed:' + error.code),
72
+ );
73
+ `;
74
+
75
+ // Resolves a public name and connects to it. Prints `connected:<ip>` so the space can try the same address.
76
+ const INTERNET_BASELINE = `${PROBE_HEAD}
77
+ setTimeout(() => done('failed:timeout'), 9000);
78
+ require('node:dns').promises.lookup('example.com', { family: 4 }).then(({ address }) => {
79
+ const socket = require('node:net').connect({ host: address, port: 443 });
80
+ socket.on('connect', () => done('connected:' + address));
81
+ socket.on('error', (error) => done('failed:' + error.code));
82
+ }, (error) => done('failed:' + error.code));
83
+ `;
84
+
85
+ // Prints one line per listening TCP socket, IPv4 and IPv6: `<local address in hex>:<port in hex>`.
86
+ const LISTENERS = "cat /proc/net/tcp /proc/net/tcp6 | awk '$4 == \"0A\" { print $2 }'";
87
+ // 127.0.0.0/8 in the kernel's byte order, or ::1. Docker's own DNS stub listens on 127.0.0.11.
88
+ const LOOPBACK_LISTENER = /^([0-9A-F]{6}7F|00000000000000000000000001000000):[0-9A-F]{4}$/;
89
+
90
+ const ALLOCATE_WITHOUT_END = 'const kept = []; for (;;) kept.push(Buffer.alloc(16 * 1024 * 1024, 1));';
91
+
92
+ // One CONNECT to the corridor, as any client in the space sends it. Prints the status line.
93
+ const corridorProbe = (target) => `${PROBE_HEAD}
94
+ const socket = require('node:net').connect({ host: '${GATEKEEPER_ALIAS}', port: ${GATEKEEPER_CORRIDOR_PORT} });
95
+ let answer = '';
96
+ socket.setTimeout(20000, () => done('failed:timeout'));
97
+ socket.on('connect', () => socket.write('CONNECT ${target} HTTP/1.1\\r\\nHost: ${target}\\r\\n\\r\\n'));
98
+ socket.on('data', (chunk) => { answer += chunk; if (answer.includes('\\r\\n')) done('answer:' + answer.split('\\r\\n')[0]); });
99
+ socket.on('error', (error) => done('failed:' + error.code));
100
+ `;
101
+
102
+ // One request through the window. Prints the status and the body, both of them data from outside.
103
+ const windowProbe = (path, headers = {}) => `${PROBE_HEAD}
104
+ const request = require('node:http').request({
105
+ host: '${GATEKEEPER_ALIAS}', port: ${GATEKEEPER_WINDOW_PORT}, path: ${JSON.stringify(path)}, method: 'GET', headers: ${JSON.stringify(headers)},
106
+ }, (answer) => {
107
+ let body = '';
108
+ answer.on('data', (chunk) => { body += chunk; });
109
+ answer.on('end', () => done(answer.statusCode + ' ' + body.slice(0, 2000)));
110
+ });
111
+ request.on('error', (error) => done('failed:' + error.code));
112
+ setTimeout(() => done('failed:timeout'), 25000);
113
+ request.end();
114
+ `;
115
+
116
+ // What a client that means harm sends. None of it may end the gatekeeper or block another client.
117
+ const ABUSE = `${PROBE_HEAD}
118
+ const net = require('node:net');
119
+ const lines = [
120
+ 'CONNECT', 'CONNECT :443 HTTP/1.1', 'CONNECT example.com: HTTP/1.1', 'CONNECT [::1:443 HTTP/1.1',
121
+ 'GET / HTTP/1.1', 'x'.repeat(40000), 'CONNECT example.com:443 HTTP/1.1\\r\\nX-Filler: ' + 'a'.repeat(100000),
122
+ ];
123
+ const send = (line, reset) => new Promise((resolve) => {
124
+ const socket = net.connect({ host: '${GATEKEEPER_ALIAS}', port: ${GATEKEEPER_CORRIDOR_PORT} });
125
+ socket.on('error', () => resolve());
126
+ socket.setTimeout(3000, () => { socket.destroy(); resolve(); });
127
+ socket.on('connect', () => { socket.write(line + '\\r\\n\\r\\n'); if (reset) socket.resetAndDestroy(); });
128
+ socket.on('close', () => resolve());
129
+ socket.on('data', () => { socket.destroy(); resolve(); });
130
+ });
131
+ const work = [];
132
+ for (let round = 0; round < 12; round += 1) {
133
+ for (const line of lines) work.push(send(line, false), send(line, true));
134
+ }
135
+ Promise.all(work).then(() => done('done:' + work.length));
136
+ `;
137
+
138
+ // Sockets without end, which is what it takes to have the gatekeeper killed for its memory.
139
+ // They connect and then say nothing, so each one sits in the corridor's header window.
140
+ const FLOOD = `${PROBE_HEAD}
141
+ const net = require('node:net');
142
+ const kept = [];
143
+ const open = (port) => new Promise((resolve) => {
144
+ const socket = net.connect({ host: '${GATEKEEPER_ALIAS}', port });
145
+ socket.on('error', () => resolve());
146
+ socket.on('connect', () => { kept.push(socket); resolve(); });
147
+ socket.on('close', () => resolve());
148
+ setTimeout(resolve, 5000);
149
+ });
150
+ const work = [];
151
+ for (let attempt = 0; attempt < 400; attempt += 1) {
152
+ work.push(open(${GATEKEEPER_CORRIDOR_PORT}), open(${GATEKEEPER_WINDOW_PORT}), open(${GATEKEEPER_CONTROL_PORT}));
153
+ }
154
+ Promise.all(work).then(() => { const held = kept.length; for (const socket of kept) socket.destroy(); setTimeout(() => done('done:' + held), 500); });
155
+ `;
156
+
157
+ // As many tunnels at once as the space can get, to a name the host allowed. Prints how many were
158
+ // established and how many the corridor refused, then lets every one of them go.
159
+ const HOLD_TUNNELS = `${PROBE_HEAD}
160
+ const net = require('node:net');
161
+ const kept = [];
162
+ const attempt = () => new Promise((resolve) => {
163
+ const socket = net.connect({ host: '${GATEKEEPER_ALIAS}', port: ${GATEKEEPER_CORRIDOR_PORT} });
164
+ let answer = '';
165
+ const end = (outcome) => resolve(outcome);
166
+ socket.on('error', () => end('failed'));
167
+ socket.setTimeout(20000, () => { socket.destroy(); end('failed'); });
168
+ socket.on('connect', () => socket.write('CONNECT example.com:443 HTTP/1.1\\r\\n\\r\\n'));
169
+ socket.on('data', (chunk) => {
170
+ answer += chunk;
171
+ if (!answer.includes('\\r\\n')) return;
172
+ if (answer.startsWith('HTTP/1.1 200')) { kept.push(socket); end('established'); return; }
173
+ socket.destroy();
174
+ // A 502 means the corridor allowed it and the connection out failed, which is the machine
175
+ // and not the cap. The test says so instead of reading it as a refusal.
176
+ end(answer.startsWith('HTTP/1.1 502') ? 'unreachable' : 'refused');
177
+ });
178
+ });
179
+ Promise.all(Array.from({ length: 80 }, attempt)).then((outcomes) => {
180
+ const counts = { established: 0, refused: 0, unreachable: 0, failed: 0 };
181
+ for (const outcome of outcomes) counts[outcome] += 1;
182
+ for (const socket of kept) socket.destroy();
183
+ setTimeout(() => done('done:' + JSON.stringify(counts)), 1000);
184
+ });
185
+ `;
186
+
187
+ // What a long-lived credential looks like: an OpenAI-style oauth record with a refresh token,
188
+ // a private key, a GitHub token, a provider key. A log line that happens to say "refresh" does
189
+ // not match, which is why each pattern carries its punctuation.
190
+ 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
+ /** 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
+ const credentialSearch = (spaceId) => `
195
+ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;
196
+ echo "auth-record: $([ -e "$HOME/.local/share/opencode/auth.json" ] && echo present || echo absent)";
197
+ echo "--- files";
198
+ grep -rIsE '${LONG_LIVED_CREDENTIAL}' "$HOME" /tmp /spaces/${spaceId} 2>/dev/null | head -20;
199
+ echo "--- environments";
200
+ for process in /proc/[0-9]*/environ; do tr '\\0' '\\n' < "$process" 2>/dev/null; done | grep -E '${LONG_LIVED_CREDENTIAL}' | head -20;
201
+ echo "--- end";
202
+ `;
203
+
204
+ const sleep = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
205
+
206
+ const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
207
+
208
+ export function runEscapeSuite(title, { enabled = true, setup }) {
209
+ describe.skipIf(!enabled)(`escape tests: ${title}`, () => {
210
+ const spec = {
211
+ id: createSpaceId(),
212
+ name: 'Escape tests',
213
+ project: hashProjectDirectory('/escape/suite/project'),
214
+ created: new Date().toISOString(),
215
+ memoryBytes: MEMORY_BYTES,
216
+ };
217
+ let place;
218
+ let host;
219
+ let dispose = async () => {};
220
+ let internetBaseline = 'not run';
221
+
222
+ const inside = (argv) => place.exec(spec.id, argv, { timeoutMs: 60_000 });
223
+ const shell = (script) => inside(['sh', '-c', script]);
224
+
225
+ // Without this, "cannot reach the internet" also passes on a machine that is offline.
226
+ const publicAddress = () => {
227
+ const address = internetBaseline.replace(/^connected:/, '').trim();
228
+ if (!internetBaseline.startsWith('connected:') || net.isIP(address) === 0) {
229
+ throw new Error(`Inconclusive: a container with normal networking could not reach example.com:443 (${internetBaseline.trim()}), so a failure inside the space proves nothing. Check the internet connection of the Docker machine.`);
230
+ }
231
+ return address;
232
+ };
233
+
234
+ beforeAll(async () => {
235
+ ({ place, dispose, host } = await setup());
236
+ await place.create(spec);
237
+ internetBaseline = (await host.runUnrestricted(INTERNET_BASELINE)).stdout;
238
+ }, CREATE_TIMEOUT_MS);
239
+
240
+ afterAll(async () => {
241
+ await dispose();
242
+ });
243
+
244
+ it('positive control: can write inside the space directory, HOME and /tmp', async () => {
245
+ const work = await shell(`echo kept > /spaces/${spec.id}/probe && cat /spaces/${spec.id}/probe`);
246
+ expect(work).toMatchObject({ code: 0, stdout: 'kept\n' });
247
+
248
+ const home = await shell('echo kept > "$HOME/probe" && cat "$HOME/probe" && echo "$HOME"');
249
+ expect(home).toMatchObject({ code: 0, stdout: 'kept\n/home/space\n' });
250
+
251
+ const tmp = await shell('echo kept > /tmp/probe && cat /tmp/probe');
252
+ expect(tmp).toMatchObject({ code: 0, stdout: 'kept\n' });
253
+ });
254
+
255
+ it('is not root and cannot become root', async () => {
256
+ expect((await inside(['id', '-u'])).stdout).toBe('1000\n');
257
+
258
+ const sudo = await shell('sudo -n id -u');
259
+ expect(sudo.code).not.toBe(0);
260
+ expect(sudo.stdout.trim()).not.toBe('0');
261
+
262
+ const su = await shell('su root -c "id -u" </dev/null');
263
+ expect(su.code).not.toBe(0);
264
+ expect(su.stdout).toBe('');
265
+ });
266
+
267
+ it('has no capabilities and cannot gain privileges', async () => {
268
+ const status = (await inside(['cat', '/proc/self/status'])).stdout;
269
+ expect(status).toMatch(/^NoNewPrivs:\s+1$/m);
270
+ expect(status).toMatch(/^CapEff:\s+0000000000000000$/m);
271
+ expect(status).toMatch(/^CapPrm:\s+0000000000000000$/m);
272
+ expect(status).toMatch(/^Seccomp:\s+2$/m);
273
+ });
274
+
275
+ it('cannot make a user namespace where it would be root', async () => {
276
+ // Positive control: the tool exists, so a failure is a refusal.
277
+ expect((await shell('command -v unshare')).code).toBe(0);
278
+ const result = await inside(['unshare', '-Ur', 'id', '-u']);
279
+ expect(result.code).not.toBe(0);
280
+ expect(result.stdout).toBe('');
281
+ });
282
+
283
+ it('cannot mount a filesystem', async () => {
284
+ expect((await shell('command -v mount')).code).toBe(0);
285
+ const result = await inside(['mount', '-t', 'tmpfs', 'none', '/mnt']);
286
+ expect(result.code).not.toBe(0);
287
+ });
288
+
289
+ it('cannot change kernel settings', async () => {
290
+ // Positive control: the file is there and readable.
291
+ expect((await inside(['cat', '/proc/sys/kernel/hostname'])).code).toBe(0);
292
+ const result = await shell('echo escaped > /proc/sys/kernel/hostname');
293
+ expect(result.code).not.toBe(0);
294
+ expect((await inside(['cat', '/proc/sys/kernel/hostname'])).stdout).not.toContain('escaped');
295
+ });
296
+
297
+ it.each(['/', '/etc', '/usr'])('cannot write to %s', async (directory) => {
298
+ const result = await shell(`touch ${directory}/openchamber-escape-probe`);
299
+ expect(result.code).not.toBe(0);
300
+ expect(result.stderr).toMatch(/Read-only file system|Permission denied/);
301
+ });
302
+
303
+ it.each(['/var/run/docker.sock', '/run/docker.sock'])('has no runtime socket at %s', async (socketPath) => {
304
+ expect((await inside(['test', '-e', socketPath])).code).not.toBe(0);
305
+ // Positive control for `test -e` itself.
306
+ expect((await inside(['test', '-e', '/etc/passwd'])).code).toBe(0);
307
+ });
308
+
309
+ it('cannot open a TCP connection to the internet', async () => {
310
+ const result = await inside(['node', '-e', tcpProbe(publicAddress(), 443)]);
311
+ expect(result.code).toBe(0);
312
+ expect(result.stdout).toMatch(/^failed:/);
313
+ });
314
+
315
+ it('cannot resolve a public name', async () => {
316
+ publicAddress();
317
+ const result = await inside(['node', '-e', DNS_PROBE]);
318
+ expect(result.code).toBe(0);
319
+ expect(result.stdout).toMatch(/^failed:/);
320
+ });
321
+
322
+ it('cannot reach a service that listens on the host of the place', async () => {
323
+ // The plain network comes first, so its gateway is among the addresses the listener reports.
324
+ const plain = await host.createPlainInternalNetwork();
325
+ let listener = { stop: async () => {} };
326
+ try {
327
+ listener = await host.startHostListener();
328
+ const { port, candidates } = listener;
329
+ const connected = (answers) => Object.entries(answers).filter(([, answer]) => answer.startsWith('connected:'));
330
+ const probe = async (run) => JSON.parse((await run(hostProbe(candidates, port))).stdout);
331
+
332
+ // Control 1: the listener is up. A container with normal networking reaches it.
333
+ let fromOutside = {};
334
+ for (let attempt = 0; attempt < 20 && connected(fromOutside).length === 0; attempt += 1) {
335
+ await sleep(250);
336
+ fromOutside = await probe(host.runUnrestricted);
337
+ }
338
+ if (connected(fromOutside).length === 0) {
339
+ throw new Error(`Inconclusive: a container with normal networking reached the host listener through none of ${candidates.join(', ')}.`);
340
+ }
341
+
342
+ // Control 2: the hole is real here and this probe sees it. A container on an internal
343
+ // network WITHOUT the space's isolation reaches the host through that network's gateway.
344
+ const fromPlain = await probe(plain.run);
345
+ if (connected(fromPlain).length === 0) {
346
+ throw new Error(`Inconclusive: from a plain internal network no host address connected (${JSON.stringify(fromPlain)}), so this probe cannot show the hole and a failure inside the space proves nothing.`);
347
+ }
348
+
349
+ // The space itself tries every candidate and every address a control reached through a name.
350
+ // All must fail. A name that does not resolve counts as failed.
351
+ const reached = [...connected(fromOutside), ...connected(fromPlain)].map(([, answer]) => answer.replace(/^connected:/, ''));
352
+ const targets = [...new Set([...candidates, ...reached.filter((address) => net.isIP(address) !== 0)])];
353
+ // Since stage 2 the `.1` address of the space's own subnet belongs to the gatekeeper, because
354
+ // it is the first container on that network. A connection to it is not an escape, and this
355
+ // test must never take it for one.
356
+ const { inner } = await host.gatekeeperAddresses(spec.id);
357
+ expect(targets, `the gatekeeper's own address ${inner} is not a host address`).not.toContain(inner);
358
+ const result = await inside(['node', '-e', hostProbe(targets, port)]);
359
+ expect(result.code).toBe(0);
360
+ const fromSpace = JSON.parse(result.stdout);
361
+ expect(Object.keys(fromSpace)).toEqual(targets);
362
+ expect(connected(fromSpace), JSON.stringify(fromSpace)).toEqual([]);
363
+ } finally {
364
+ await listener.stop();
365
+ await plain.remove();
366
+ }
367
+ }, 180_000);
368
+
369
+ it('sees no host path in its mounts', async () => {
370
+ const result = await inside(['mount']);
371
+ expect(result.code).toBe(0);
372
+ // Positive control: the listing is real and shows the space volume.
373
+ expect(result.stdout).toContain(`/spaces/${spec.id}`);
374
+
375
+ const hostUser = os.userInfo().username;
376
+ for (const hostPath of ['/Users', `/home/${hostUser}`, '/host', '/mnt/host', 'docker.sock']) {
377
+ const asPathSegment = new RegExp(`${hostPath.replace(/[.]/g, '\\.')}(/|\\s|$)`, 'm');
378
+ expect(result.stdout).not.toMatch(asPathSegment);
379
+ }
380
+ });
381
+
382
+ describe('tools volume', () => {
383
+ const launcher = `${TOOLS_BIN_PATH}/openchamber`;
384
+ const fingerprint = async () => (await shell(`sha256sum ${TOOLS_MARKER_PATH} "$(readlink -f ${launcher})" && ls -la ${TOOLS_MOUNT_PATH} ${TOOLS_BIN_PATH}`)).stdout;
385
+
386
+ it('positive control: the tools are there, and the programs the space runs come from them', async () => {
387
+ const version = await inside(['openchamber', '--version']);
388
+ expect(version.code).toBe(0);
389
+ expect(version.stdout).toMatch(/^\d+\.\d+\.\d+/);
390
+ expect((await shell('command -v openchamber && command -v opencode')).stdout).toBe(`${launcher}\n${TOOLS_BIN_PATH}/opencode\n`);
391
+
392
+ // The server that runs right now was started from the mount, and so was its OpenCode.
393
+ const running = (await shell("for pid in /proc/[0-9]*; do tr '\\0' ' ' < $pid/cmdline; echo; done")).stdout;
394
+ expect(running).toMatch(new RegExp(`node ${launcher} serve --foreground`));
395
+ expect(running).toMatch(new RegExp(`${TOOLS_MOUNT_PATH}/node_modules/\\S*opencode\\S* serve`));
396
+ });
397
+
398
+ it('is mounted read-only', async () => {
399
+ const mounts = (await inside(['cat', '/proc/mounts'])).stdout.split('\n').filter((line) => line.split(' ')[1] === TOOLS_MOUNT_PATH);
400
+ expect(mounts).toHaveLength(1);
401
+ expect(mounts[0].split(' ')[3].split(',')).toContain('ro');
402
+ });
403
+
404
+ it.each([
405
+ ['write a new file', `touch ${TOOLS_MOUNT_PATH}/openchamber-escape-probe`],
406
+ ['write into node_modules', `touch ${TOOLS_BIN_PATH}/openchamber-escape-probe`],
407
+ ['overwrite the launcher', `echo 'echo escaped' > "$(readlink -f ${launcher})"`],
408
+ ['replace the launcher', `cp /bin/true /tmp/replacement && mv /tmp/replacement ${launcher}`],
409
+ ['replace the launcher with a link', `ln -sfn /bin/true ${launcher}`],
410
+ ['delete the launcher', `rm -f ${launcher}`],
411
+ ['delete the fill marker', `rm -f ${TOOLS_MARKER_PATH}`],
412
+ ['rename a directory', `mv ${TOOLS_MOUNT_PATH}/node_modules ${TOOLS_MOUNT_PATH}/node_modules-moved`],
413
+ ['change permissions', `chmod 777 ${TOOLS_MOUNT_PATH}`],
414
+ ['change permissions of the launcher', `chmod 777 "$(readlink -f ${launcher})"`],
415
+ ])('cannot %s', async (title, script) => {
416
+ const before = await fingerprint();
417
+ // Positive control: the fingerprint is real, so "unchanged" below means something.
418
+ expect(before).toMatch(/^[0-9a-f]{64} /);
419
+
420
+ const result = await shell(script);
421
+ expect(result.code).not.toBe(0);
422
+ // Root owns these files, so "Permission denied" would also stop this user on a WRITABLE mount
423
+ // and would prove nothing about the mount. Only the kernel's answer for a read-only mount counts.
424
+ expect(result.stderr).toMatch(/Read-only file system/);
425
+ expect(result.stderr).not.toMatch(/Permission denied|Operation not permitted/);
426
+ expect(await fingerprint()).toBe(before);
427
+ });
428
+
429
+ // The one attempt that the read-only mount does not get to answer. To open an existing file
430
+ // without truncating it, the kernel checks the file's permissions first, and root owns the file.
431
+ // So this proves the ownership, not the mount. The attempts above prove the mount.
432
+ it('cannot append to the launcher', async () => {
433
+ const before = await fingerprint();
434
+ const result = await shell(`echo 'echo escaped' >> "$(readlink -f ${launcher})"`);
435
+ expect(result.code).not.toBe(0);
436
+ expect(result.stderr).toMatch(/Permission denied|Read-only file system/);
437
+ expect(await fingerprint()).toBe(before);
438
+ });
439
+
440
+ it('cannot remount it writable', async () => {
441
+ expect((await shell('command -v mount')).code).toBe(0);
442
+ const result = await inside(['mount', '-o', 'remount,rw', TOOLS_MOUNT_PATH]);
443
+ expect(result.code).not.toBe(0);
444
+ expect((await shell(`touch ${TOOLS_MOUNT_PATH}/openchamber-escape-probe`)).code).not.toBe(0);
445
+ });
446
+ });
447
+
448
+ it('keeps the server token out of everything the place records about the container', async () => {
449
+ const server = createSpaceServerChannel({ exec: place.exec });
450
+ const token = await server.readToken(spec.id);
451
+ // Positive control: this is the real token. The server inside accepts it and refuses another one.
452
+ expect(token.length).toBeGreaterThanOrEqual(32);
453
+ const login = (password) => server.request(spec.id, { method: 'POST', path: '/auth/session', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
454
+ expect((await login(token)).status).toBe(200);
455
+ expect((await login(`${token}x`)).status).toBe(401);
456
+
457
+ const metadata = await host.spaceMetadata(spec.id);
458
+ // Positive control: the metadata is real and complete enough to show the environment.
459
+ expect(metadata).toContain('OPENCODE_DISABLE_AUTOUPDATE=1');
460
+ expect(metadata).not.toContain(token);
461
+ expect(metadata).not.toContain('OPENCODE_AUTH_CONTENT');
462
+ });
463
+
464
+ it('cannot steer the requests of the host through its own ~/.curlrc', async () => {
465
+ const server = createSpaceServerChannel({ exec: place.exec });
466
+ try {
467
+ await shell(`printf 'write-out = "INJECTED-BY-THE-AGENT"\\n' > "$HOME/.curlrc"`);
468
+ // Positive control: this curl does read the file, so an unprotected request would carry the text.
469
+ const unprotected = await shell(`curl --silent http://127.0.0.1:${SPACE_SERVER_PORT}/health`);
470
+ expect(unprotected.stdout).toContain('INJECTED-BY-THE-AGENT');
471
+
472
+ const answer = await server.request(spec.id, { path: '/health' });
473
+ expect(answer.status).toBe(200);
474
+ expect(answer.body).not.toContain('INJECTED-BY-THE-AGENT');
475
+ expect(JSON.parse(answer.body)).toMatchObject({ isOpenCodeReady: true });
476
+ } finally {
477
+ await shell('rm -f "$HOME/.curlrc"');
478
+ }
479
+ });
480
+
481
+ it('has no listener that faces the space network', async () => {
482
+ const listeners = (await shell(LISTENERS)).stdout.split('\n').filter(Boolean);
483
+ // Positive control: the server inside is among them, so the listing is real.
484
+ expect(listeners).toContain(`0100007F:${SPACE_SERVER_PORT.toString(16).toUpperCase().padStart(4, '0')}`);
485
+ expect(listeners.filter((listener) => !LOOPBACK_LISTENER.test(listener))).toEqual([]);
486
+ });
487
+
488
+ it('cannot fill the disk of the host through its own output', async () => {
489
+ // 50 MB to the stdout of PID 1, which is what the place keeps as the container log.
490
+ const flood = await shell('head -c 52428800 /dev/zero | tr "\\0" x | fold -w 1000 > /proc/1/fd/1');
491
+ // Positive control: the write went through, and the host kept some of it.
492
+ expect(flood.code).toBe(0);
493
+ const kept = await host.logBytes(spec.id);
494
+ expect(kept).toBeGreaterThan(0);
495
+ expect(kept).toBeLessThan(LOG_CAP_BYTES);
496
+ }, 120_000);
497
+
498
+ describe('gatekeeper', () => {
499
+ // The key the gatekeeper holds for the space. It is made here and never enters the space.
500
+ const MODEL_KEY = `sk-live-${crypto.randomBytes(24).toString('hex')}`;
501
+ const channel = () => createGatekeeperChannel({ exec: place.exec });
502
+ const probe = async (script) => {
503
+ const result = await inside(['node', '-e', script]);
504
+ expect(result.code, result.stderr).toBe(0);
505
+ return result.stdout.trim();
506
+ };
507
+ const inGatekeeper = (argv) => place.exec(spec.id, argv, { timeoutMs: 60_000, target: 'gatekeeper' });
508
+
509
+ /**
510
+ * A CONNECT that must go through. A 502 means the corridor allowed the target and the
511
+ * connection out did not happen: the Docker machine's link, not the restriction under test.
512
+ * That ends the test as inconclusive, with the host and the answer named, the same way the
513
+ * internet baseline does. The refusals stay hard assertions, because they are the point.
514
+ */
515
+ const mustCarry = async (target) => {
516
+ const answer = await probe(corridorProbe(target));
517
+ if (/^answer:HTTP\/1\.1 502 /.test(answer)) {
518
+ throw new Error(`Inconclusive: the corridor allowed ${target} and the Docker machine could not reach it (${answer}). While that is true, what is refused below says nothing about the restriction.`);
519
+ }
520
+ expect(answer, target).toMatch(/^answer:HTTP\/1\.1 200 /);
521
+ return answer;
522
+ };
523
+ const allow = (domains) => channel().setNetwork(spec.id, { mode: 'allowlist', domains });
524
+ const open = () => channel().setNetwork(spec.id, { mode: 'open', domains: [] });
525
+ /**
526
+ * Where the journal stands now, counted in records ever written: the ring plus what has
527
+ * fallen out of it. A mark, never a time. The record's stamp comes from a clock inside a
528
+ * container and the test reads one on the host, and nothing makes those two agree to the
529
+ * millisecond; on Windows they were measured disagreeing by a timer tick, which is enough
530
+ * to hide a refusal decided the instant it was asked for.
531
+ */
532
+ const journalMark = async () => {
533
+ const { records, dropped } = await channel().readJournal(spec.id);
534
+ return records.length + dropped;
535
+ };
536
+
537
+ /**
538
+ * The last decision about this destination among the records written since `mark`. Without
539
+ * the mark a test reads a record an earlier test left behind and passes on it.
540
+ */
541
+ const decisionFor = async (host, mark = 0) => {
542
+ const { records, dropped } = await channel().readJournal(spec.id);
543
+ const since = Math.max(0, records.length + dropped - mark);
544
+ const fresh = records.slice(Math.max(0, records.length - since)).filter((entry) => entry.host === host);
545
+ expect(fresh.length, `the gatekeeper decided nothing about ${host}`).toBeGreaterThan(0);
546
+ return fresh.at(-1);
547
+ };
548
+
549
+ // The two names this control carries to are both IANA's reserved documentation domains, so
550
+ // the whole live suite leans on one operator rather than two. It used to allow a product
551
+ // service for the second half, and on one machine that host answered 502 and reported a
552
+ // failed restriction where the restriction had worked.
553
+ it('positive control: the corridor carries what the host allowed, and refuses everything else', async () => {
554
+ publicAddress();
555
+ await allow(['example.com']);
556
+
557
+ await mustCarry('example.com:443');
558
+ expect(await probe(corridorProbe('example.org:443'))).toMatch(/^answer:HTTP\/1\.1 403 /);
559
+ expect((await decisionFor('example.org')).decision).toBe('deny:not-on-allowlist');
560
+ // The allowlist changes live: no restart, and the next attempt is decided by the new list.
561
+ await allow(['example.org']);
562
+ await mustCarry('example.org:443');
563
+ expect(await probe(corridorProbe('example.com:443'))).toMatch(/^answer:HTTP\/1\.1 403 /);
564
+ }, 120_000);
565
+
566
+ // There is no live "carries ten megabytes" test here, and that is deliberate. It pulled a
567
+ // published tarball from the npm registry, so a bad minute at somebody else's service made a
568
+ // run inconclusive, which is a failed run with no evidence in it. What it was for — that a
569
+ // large transfer arrives whole — is proved better without a network: `joinSockets` in
570
+ // `gatekeeper-program.test.js` carries four megabytes full duplex while the space is still
571
+ // uploading and compares sha256, it is byte-exact, and it fails when the teardown goes back.
572
+ // The live half of the claim, that the corridor carries to a real host through a real
573
+ // container, is the positive control above. Do not add a large download back thinking this
574
+ // path is uncovered.
575
+
576
+ it('cannot reach auth.openai.com in allowlist mode, even with it on the list', async () => {
577
+ await allow(['auth.openai.com', 'example.com']);
578
+ expect(await probe(corridorProbe('auth.openai.com:443'))).toMatch(/^answer:HTTP\/1\.1 403 /);
579
+ expect((await decisionFor('auth.openai.com')).decision).toBe('deny:always-refused');
580
+ // In this mode that is the whole story: only the names on the list pass at all.
581
+ }, 120_000);
582
+
583
+ // In open mode this is a lock and not a guarantee, and the test says no more than it sees.
584
+ // A space in open mode reaches every public host on 443, so it can go through a third-party
585
+ // intermediary and the corridor sees only that name. What holds in both modes is that the
586
+ // long-lived refresh token is never inside the space, which is the test below.
587
+ it('is refused for the name and for the address of auth.openai.com in open mode, which is a lock and not a guarantee', async () => {
588
+ await open();
589
+ expect(await probe(corridorProbe('auth.openai.com:443'))).toMatch(/^answer:HTTP\/1\.1 403 /);
590
+ expect((await decisionFor('auth.openai.com')).decision).toBe('deny:always-refused');
591
+ }, 120_000);
592
+
593
+ // A refusal that is only about a name is no refusal. The space cannot resolve a public name
594
+ // itself, but in open mode it could ask a public DNS-over-HTTPS resolver through this same
595
+ // corridor and then connect to the address. So every form of the address must be refused too.
596
+ it('refuses the address of a refused name as well, in either mode', async () => {
597
+ const answers = await dns.lookup('auth.openai.com', { all: true, family: 4 }).catch(() => []);
598
+ if (answers.length === 0) {
599
+ throw new Error('Inconclusive: auth.openai.com did not resolve here, so the address form cannot be tried.');
600
+ }
601
+ const address = answers[0].address;
602
+ const octets = address.split('.').map(Number);
603
+ const asInteger = ((octets[0] * 256 + octets[1]) * 256 + octets[2]) * 256 + octets[3];
604
+ const targets = [
605
+ `${address}:443`,
606
+ `${address}:8443`,
607
+ `[::ffff:${address}]:443`,
608
+ `[2002:${octets.slice(0, 2).map((part) => part.toString(16).padStart(2, '0')).join('')}:0::1]:443`,
609
+ // The same address as one integer, and in hex, and in three parts. Each of these is an
610
+ // address to the C library of this image, and none of them is four dotted octets. The
611
+ // first one carried a real TLS session to this host through the corridor in an earlier draft.
612
+ `${asInteger}:443`,
613
+ `0x${asInteger.toString(16).padStart(8, '0')}:443`,
614
+ `${octets[0]}.${octets[1]}.${octets[2] * 256 + octets[3]}:443`,
615
+ ];
616
+
617
+ for (const change of [() => allow(['auth.openai.com', 'example.com']), open]) {
618
+ await change();
619
+ for (const target of targets) {
620
+ expect(await probe(corridorProbe(target)), target).toMatch(/^answer:HTTP\/1\.1 403 /);
621
+ }
622
+ // The corridor takes names, so this is the reason, not the allowlist and not the port.
623
+ expect((await decisionFor(address)).decision).toBe('deny:not-a-name');
624
+ }
625
+
626
+ // Control: the gatekeeper itself reaches that address, so the refusal is the corridor's.
627
+ const reached = (await inGatekeeper(['node', '-e', tcpProbe(address, 443)])).stdout.trim();
628
+ if (!reached.startsWith('connected:')) {
629
+ throw new Error(`Inconclusive: the gatekeeper could not reach ${address} either (${reached}).`);
630
+ }
631
+ }, 180_000);
632
+
633
+ it('reaches an allowed name on 443 and on no other port, in either mode', async () => {
634
+ publicAddress();
635
+ // Not a speed bump: over any other port a space could attack a third party from the
636
+ // user's address, and nothing about open mode changes that.
637
+ for (const change of [() => allow(['example.com']), open]) {
638
+ await change();
639
+ await mustCarry('example.com:443');
640
+ for (const port of [80, 22, 8443]) {
641
+ const at = await journalMark();
642
+ expect(await probe(corridorProbe(`example.com:${port}`)), String(port)).toMatch(/^answer:HTTP\/1\.1 403 /);
643
+ expect((await decisionFor('example.com', at)).decision).toBe('deny:port');
644
+ }
645
+ }
646
+ }, 180_000);
647
+
648
+ // Two things at once. Every address target is refused before the corridor looks at where it
649
+ // points, which is the name rule. Every name Docker injects for its host, where it resolves,
650
+ // is refused by the address it resolves to: measured with the address block removed,
651
+ // `host.docker.internal` answered 502 instead of 403, so that half is the block doing the work.
652
+ it('cannot ask the corridor for the Docker host, by address or by the names Docker injects', async () => {
653
+ const listener = await host.startHostListener();
654
+ try {
655
+ const { port, candidates } = listener;
656
+ const addresses = candidates.filter((candidate) => net.isIP(candidate) !== 0);
657
+
658
+ // Control: the gatekeeper itself can reach the host. So a refusal below is the corridor's
659
+ // doing and not a missing route. This is the whole point of the test.
660
+ const fromGatekeeper = JSON.parse((await inGatekeeper(['node', '-e', hostProbe(candidates, port)])).stdout);
661
+ const reached = Object.entries(fromGatekeeper).filter(([, answer]) => answer.startsWith('connected:'));
662
+ if (reached.length === 0) {
663
+ throw new Error(`Inconclusive: the gatekeeper reached the host through none of ${candidates.join(', ')} (${JSON.stringify(fromGatekeeper)}), so a refusal proves nothing.`);
664
+ }
665
+
666
+ for (const change of [() => allow(['example.com']), open]) {
667
+ await change();
668
+ for (const address of addresses) {
669
+ // On 443, where the corridor's port rule is out of the way, and on the listener's port.
670
+ expect(await probe(corridorProbe(`${address}:443`)), address).toMatch(/^answer:HTTP\/1\.1 403 /);
671
+ expect(await probe(corridorProbe(`${address}:${port}`)), address).toMatch(/^answer:HTTP\/1\.1 403 /);
672
+ }
673
+ // The IPv4-mapped IPv6 form of the same addresses is the same address.
674
+ for (const address of addresses) {
675
+ expect(await probe(corridorProbe(`[::ffff:${address}]:443`)), address).toMatch(/^answer:HTTP\/1\.1 403 /);
676
+ }
677
+ // And by a name that Docker Desktop injects for the host, where it resolves.
678
+ for (const name of candidates.filter((candidate) => net.isIP(candidate) === 0)) {
679
+ expect(await probe(corridorProbe(`${name}:443`)), name).toMatch(/^answer:HTTP\/1\.1 403 /);
680
+ }
681
+ }
682
+ } finally {
683
+ await listener.stop();
684
+ }
685
+ }, 300_000);
686
+
687
+ // Since the corridor takes names only, an address in a CONNECT target is refused before the
688
+ // address block is consulted. So the block is proved here the one way that still reaches it:
689
+ // by a name that resolves into a range it refuses. That name used to come from a third-party
690
+ // wildcard DNS service, and twice a bad minute at one of those turned a run on somebody's
691
+ // machine into an inconclusive one, which is a failed run with no evidence in it. A test
692
+ // that cries wolf gets ignored, so the name is ours now.
693
+ it('cannot reach a private address through the corridor by a name that resolves to it', async () => {
694
+ publicAddress();
695
+ const blocked = await host.startBlockedName(spec.id);
696
+ try {
697
+ // Docker's own resolver answers this name, with the helper's address on the space's
698
+ // outer bridge. Nothing outside this machine is involved in the verdict.
699
+ expect(blocked.address, 'the helper container has no address on the outer network').toMatch(/^\d+\.\d+\.\d+\.\d+$/);
700
+ const isPrivate = /^(10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(blocked.address);
701
+ if (!isPrivate) {
702
+ throw new Error(`Inconclusive: this Docker daemon put the helper on ${blocked.address}, which is not a range the corridor refuses, so the address rule cannot be shown this way here.`);
703
+ }
704
+ // Allowed by name in one mode and by open mode in the other, so what refuses it is the
705
+ // address it resolves to and nothing about the name.
706
+ for (const change of [() => allow([blocked.alias]), open]) {
707
+ await change();
708
+ const at = await journalMark();
709
+ expect(await probe(corridorProbe(`${blocked.alias}:443`))).toMatch(/^answer:HTTP\/1\.1 403 /);
710
+ // Not inconclusive if this does not resolve: it is our name in our resolver, so a
711
+ // name that does not answer is this suite being wrong about its own setup.
712
+ expect((await decisionFor(blocked.alias, at)).decision).toBe('deny:blocked-address');
713
+ }
714
+ } finally {
715
+ await blocked.stop();
716
+ }
717
+ }, 240_000);
718
+
719
+ it('cannot reach the control channel of its own gatekeeper, on any address it has', async () => {
720
+ const addresses = await host.gatekeeperAddresses(spec.id);
721
+ expect(addresses.inner, 'the gatekeeper has no address on the inner network').toMatch(/\d+\.\d+\.\d+\.\d+/);
722
+
723
+ // Positive control: the space can reach the gatekeeper. It is the corridor it reaches.
724
+ expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_CORRIDOR_PORT))).toMatch(/^connected:/);
725
+ expect(await probe(tcpProbe(addresses.inner, GATEKEEPER_CORRIDOR_PORT))).toMatch(/^connected:/);
726
+
727
+ for (const address of [GATEKEEPER_ALIAS, addresses.inner, addresses.outer, '127.0.0.1'].filter(Boolean)) {
728
+ expect(await probe(tcpProbe(address, GATEKEEPER_CONTROL_PORT)), address).toMatch(/^failed:/);
729
+ }
730
+ // Not through the corridor either: loopback is a blocked address, and the control port is not 443.
731
+ for (const target of [`127.0.0.1:${GATEKEEPER_CONTROL_PORT}`, `${addresses.inner}:${GATEKEEPER_CONTROL_PORT}`, `[::1]:${GATEKEEPER_CONTROL_PORT}`]) {
732
+ expect(await probe(corridorProbe(target)), target).toMatch(/^answer:HTTP\/1\.1 403 /);
733
+ }
734
+ // And not through the window: it serves grants and nothing else.
735
+ expect(await probe(windowProbe('/network'))).toMatch(/^403 /);
736
+ expect(await probe(windowProbe('/model/../network'))).toMatch(/^403 /);
737
+ }, 180_000);
738
+
739
+ it('finds the gatekeeper at the .1 address of its own subnet, which is no proof of a host', async () => {
740
+ // Measured on Docker 29.2.1: the first container on the isolated network gets `.1`, and the
741
+ // gatekeeper is created first. An escape test must never read that address as the Docker host.
742
+ const { inner } = await host.gatekeeperAddresses(spec.id);
743
+ const answer = await probe(tcpProbe(inner, GATEKEEPER_CORRIDOR_PORT));
744
+ expect(answer).toMatch(/^connected:/);
745
+ expect(inner.endsWith('.1'), `the gatekeeper is at ${inner}`).toBe(true);
746
+ });
747
+
748
+ describe('the model key', () => {
749
+ let upstream = { stop: async () => {} };
750
+
751
+ beforeAll(async () => {
752
+ upstream = await host.startWindowUpstream(spec.id);
753
+ await channel().addGrant(spec.id, { id: 'anthropic', upstream: upstream.url, header: 'x-api-key', secret: MODEL_KEY });
754
+ await channel().addGrant(spec.id, { id: 'openai', upstream: upstream.url, header: 'authorization', secret: MODEL_KEY });
755
+ }, 120_000);
756
+
757
+ afterAll(async () => {
758
+ await upstream.stop();
759
+ });
760
+
761
+ it('positive control: the key is real and the window carries it to the upstream', async () => {
762
+ const answer = await probe(windowProbe('/model/anthropic/messages?stream=true', { 'x-api-key': 'sk-fake-inside', authorization: 'Bearer sk-fake-inside' }));
763
+ expect(answer).toMatch(/^200 /);
764
+ const seen = JSON.parse(answer.slice(4));
765
+ // The upstream saw the gatekeeper's key, not what the space sent.
766
+ expect(seen.apiKey).toBe(sha256(MODEL_KEY));
767
+ expect(seen.authorization).toBe('none');
768
+ expect(seen.path).toBe('/v1/messages?stream=true');
769
+
770
+ const bearer = JSON.parse((await probe(windowProbe('/model/openai/responses'))).slice(4));
771
+ expect(bearer.authorization).toBe(sha256(`Bearer ${MODEL_KEY}`));
772
+ expect(bearer.apiKey).toBe('none');
773
+ }, 120_000);
774
+
775
+ it('is nowhere the space can look', async () => {
776
+ const answer = await probe(windowProbe('/model/anthropic/messages'));
777
+ expect(answer).not.toContain(MODEL_KEY);
778
+ // An unknown grant, a refused destination: no error text carries it either.
779
+ expect(await probe(windowProbe('/model/nothing/here'))).not.toContain(MODEL_KEY);
780
+
781
+ // The space prints what it has, and the host looks for the key in it. Nothing of the
782
+ // key is sent inside, not even a piece of it as a pattern to search for.
783
+ const insideTheSpace = await shell(`env; ps auxeww; find /home/space /tmp /spaces/${spec.id} -type f -size -1M -exec cat {} + 2>/dev/null; true`);
784
+ expect(insideTheSpace.stdout.length).toBeGreaterThan(0);
785
+ expect(insideTheSpace.stdout).not.toContain(MODEL_KEY);
786
+
787
+ // Not in what the place records about either container.
788
+ expect(await host.spaceMetadata(spec.id)).not.toContain(MODEL_KEY);
789
+ const gatekeeperMetadata = await host.gatekeeperMetadata(spec.id);
790
+ // Positive control: the metadata is real and complete enough to show the environment.
791
+ expect(gatekeeperMetadata).toContain('HOME=/tmp');
792
+ expect(gatekeeperMetadata).not.toContain(MODEL_KEY);
793
+
794
+ // And not in the journal, which is the one thing about the gatekeeper the host shows a user.
795
+ const journal = await channel().readJournal(spec.id);
796
+ expect(JSON.stringify(journal)).not.toContain(MODEL_KEY);
797
+ }, 120_000);
798
+
799
+ it('reaches a real upstream over TLS, with no credential of anyone\'s', async () => {
800
+ publicAddress();
801
+ await channel().addGrant(spec.id, { id: 'tls', upstream: 'https://example.com/', header: 'authorization', secret: 'not-a-credential' });
802
+ const answer = await probe(windowProbe('/model/tls/'));
803
+ if (answer.startsWith('502 ')) {
804
+ throw new Error(`Inconclusive: the window allowed the upstream and the Docker machine could not reach it (${answer}).`);
805
+ }
806
+ expect(answer).toMatch(/^200 /);
807
+ expect(answer).toContain('Example Domain');
808
+ }, 120_000);
809
+
810
+ it('keeps paths, queries, bodies and header values out of the journal', async () => {
811
+ await probe(windowProbe('/model/anthropic/messages?secret-query=leak-me', { authorization: 'Bearer leak-me-too' }));
812
+ await probe(corridorProbe('leak-me.example.com:443'));
813
+
814
+ const journal = await channel().readJournal(spec.id);
815
+ expect(journal.records.length).toBeGreaterThan(0);
816
+ for (const record of journal.records) {
817
+ expect(Object.keys(record).sort()).toEqual(['at', 'decision', 'host', 'listener', 'port']);
818
+ }
819
+ const text = JSON.stringify(journal);
820
+ expect(text).not.toContain('leak-me-too');
821
+ expect(text).not.toContain('secret-query');
822
+ expect(text).not.toContain('/v1/messages');
823
+ // The host of an attempt is recorded, and that is the point of the journal.
824
+ expect(text).toContain('leak-me.example.com');
825
+ }, 120_000);
826
+ });
827
+
828
+ it('cannot crash or wedge the gatekeeper with a client that means harm', async () => {
829
+ publicAddress();
830
+ await allow(['example.com']);
831
+ expect(await probe(ABUSE)).toMatch(/^done:/);
832
+
833
+ // Afterwards the corridor still serves a legitimate request, and the space is still healthy.
834
+ await mustCarry('example.com:443');
835
+ const health = await createSpaceServerChannel({ exec: place.exec }).request(spec.id, { path: '/health' });
836
+ expect(JSON.parse(health.body)).toMatchObject({ isOpenCodeReady: true });
837
+ expect((await place.list()).find((space) => space.id === spec.id)).toMatchObject({ state: 'running', damaged: false });
838
+ }, 240_000);
839
+
840
+ // The gatekeeper holds every record of what the space tried in its own memory, so a space
841
+ // that can have it killed erases that record too. It has 256 MiB and three listeners.
842
+ it('cannot drown the gatekeeper in sockets, and never takes the journal with it', async () => {
843
+ publicAddress();
844
+ await allow(['example.com']);
845
+ const before = (await channel().readJournal(spec.id)).records.length;
846
+
847
+ expect(await probe(FLOOD)).toMatch(/^done:/);
848
+
849
+ // It is alive, its journal survived, and the corridor serves again.
850
+ const journal = await channel().readJournal(spec.id);
851
+ expect(journal.records.length).toBeGreaterThanOrEqual(Math.min(before, 1));
852
+ expect(journal.records.filter((entry) => entry.decision === 'deny:too-many-connections').length).toBeLessThan(5);
853
+ await mustCarry('example.com:443');
854
+ expect((await place.verify(spec.id))).toEqual([]);
855
+ expect((await place.list()).find((space) => space.id === spec.id)).toMatchObject({ state: 'running', damaged: false });
856
+ }, 300_000);
857
+
858
+ it('cannot hold more tunnels open than the corridor allows, and gets them back when it lets go', async () => {
859
+ publicAddress();
860
+ await allow(['example.com']);
861
+
862
+ const answer = await probe(HOLD_TUNNELS);
863
+ const counts = JSON.parse(answer.replace(/^done:/, ''));
864
+ if (counts.established === 0) {
865
+ throw new Error(`Inconclusive: no tunnel to the allowed host was established (${JSON.stringify(counts)}), so this says nothing about the cap. Check the Docker machine's connection.`);
866
+ }
867
+ // More than the cap were opened at once, so some were refused, and the rest were tunnels.
868
+ expect(counts.established).toBeLessThanOrEqual(64);
869
+ expect(counts.refused).toBeGreaterThan(0);
870
+ expect((await channel().readJournal(spec.id)).records.some((entry) => entry.decision === 'deny:too-many-tunnels')).toBe(true);
871
+
872
+ // Once the space lets go, the corridor carries a tunnel again.
873
+ await mustCarry('example.com:443');
874
+ }, 300_000);
875
+
876
+ // The one guarantee that is ours in both modes: no long-lived credential of the user's is
877
+ // ever inside a space. Stage 2 puts none there, and this fails the day something does.
878
+ it('holds no long-lived credential of the user\'s, anywhere the space can read', async () => {
879
+ const search = () => shell(credentialSearch(spec.id));
880
+ const decoyRecord = `${SPACE_HOME}/.local/share/opencode/auth.json`;
881
+ const decoy = 'sk-decoy00000000000000000000';
882
+
883
+ // 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.
885
+ const planted = await shell([
886
+ `mkdir -p "$(dirname ${decoyRecord})"`,
887
+ `printf '{"openai":{"type":"oauth","refresh":"decoy-refresh-value"}}' > ${decoyRecord}`,
888
+ `printf -- '-----BEGIN OPENSSH PRIVATE KEY-----' > "$HOME/decoy-key"`,
889
+ `DECOY_TOKEN=${decoy} sleep 30 & echo "decoy-pid:$!"`,
890
+ ].join('; '));
891
+ const decoyPid = /decoy-pid:(\d+)/.exec(planted.stdout)?.[1];
892
+ try {
893
+ const control = (await search()).stdout;
894
+ expect(control).toContain('auth-record: present');
895
+ expect(control).toMatch(/"refresh"\s*:\s*"/);
896
+ expect(control).toContain('BEGIN OPENSSH PRIVATE KEY');
897
+ expect(control).toContain(decoy);
898
+ } finally {
899
+ await shell(`rm -f ${decoyRecord} "$HOME/decoy-key"; ${decoyPid ? `kill ${decoyPid} 2>/dev/null` : 'true'}; true`);
900
+ }
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');
909
+ }, 180_000);
910
+
911
+ // One space is not enough to say anything about two. This makes a second one and looks
912
+ // from the first at everything the second has. The second create costs about four seconds
913
+ // here, because both spaces share this owner's tools volume.
914
+ describe('another space of the same owner', () => {
915
+ const other = {
916
+ id: createSpaceId(),
917
+ name: 'Escape tests, the other space',
918
+ project: hashProjectDirectory('/escape/suite/other'),
919
+ created: new Date().toISOString(),
920
+ memoryBytes: MEMORY_BYTES,
921
+ };
922
+ let addresses = { space: '', gatekeeper: { inner: '', outer: '' } };
923
+
924
+ beforeAll(async () => {
925
+ await place.create(other);
926
+ addresses = {
927
+ space: await host.spaceAddress(other.id),
928
+ gatekeeper: await host.gatekeeperAddresses(other.id),
929
+ };
930
+ }, CREATE_TIMEOUT_MS);
931
+
932
+ afterAll(async () => {
933
+ await place.remove(other.id);
934
+ });
935
+
936
+ it('positive control: the other space is up, and each space reaches its own gatekeeper', async () => {
937
+ expect(addresses.space).toMatch(/\d+\.\d+\.\d+\.\d+/);
938
+ expect(addresses.gatekeeper.inner).toMatch(/\d+\.\d+\.\d+\.\d+/);
939
+ expect(addresses.gatekeeper.outer).toMatch(/\d+\.\d+\.\d+\.\d+/);
940
+
941
+ // The other space's server answers, so that space is alive and listening.
942
+ const health = await createSpaceServerChannel({ exec: place.exec }).request(other.id, { path: '/health' });
943
+ expect(JSON.parse(health.body)).toMatchObject({ isOpenCodeReady: true });
944
+
945
+ // And each space reaches the corridor of its own gatekeeper at that same port, so a
946
+ // refusal below is the boundary between them and not a listener that is not there.
947
+ const fromOther = await place.exec(other.id, ['node', '-e', tcpProbe(addresses.gatekeeper.inner, GATEKEEPER_CORRIDOR_PORT)], { timeoutMs: 60_000 });
948
+ expect(fromOther.stdout.trim()).toMatch(/^connected:/);
949
+ expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_CORRIDOR_PORT))).toMatch(/^connected:/);
950
+ }, 120_000);
951
+
952
+ it('cannot reach the other space, its gatekeeper, or anything they listen on', async () => {
953
+ const targets = [
954
+ [addresses.space, SPACE_SERVER_PORT],
955
+ [addresses.gatekeeper.inner, GATEKEEPER_CORRIDOR_PORT],
956
+ [addresses.gatekeeper.inner, GATEKEEPER_WINDOW_PORT],
957
+ [addresses.gatekeeper.inner, GATEKEEPER_CONTROL_PORT],
958
+ [addresses.gatekeeper.outer, GATEKEEPER_CORRIDOR_PORT],
959
+ [addresses.gatekeeper.outer, GATEKEEPER_WINDOW_PORT],
960
+ [addresses.gatekeeper.outer, GATEKEEPER_CONTROL_PORT],
961
+ ];
962
+ for (const [address, port] of targets) {
963
+ expect(await probe(tcpProbe(address, port)), `${address}:${port}`).toMatch(/^failed:/);
964
+ }
965
+ }, 180_000);
966
+
967
+ // Addresses again, so this is the name rule doing the work. That the other space is out
968
+ // of reach at all is the direct probe above.
969
+ it('cannot ask its own corridor for the other space either, in either mode', async () => {
970
+ for (const change of [() => allow(['example.com']), open]) {
971
+ await change();
972
+ for (const address of [addresses.space, addresses.gatekeeper.inner, addresses.gatekeeper.outer]) {
973
+ expect(await probe(corridorProbe(`${address}:443`)), address).toMatch(/^answer:HTTP\/1\.1 403 /);
974
+ }
975
+ }
976
+ }, 180_000);
977
+
978
+ it('finds its own gatekeeper under the shared name, never the other space\'s', async () => {
979
+ const mine = await host.gatekeeperAddresses(spec.id);
980
+ const resolved = await probe(`${PROBE_HEAD}
981
+ require('node:dns').promises.lookup('${GATEKEEPER_ALIAS}').then((answer) => done('resolved:' + answer.address), (error) => done('failed:' + error.code));
982
+ `);
983
+ expect(resolved).toBe(`resolved:${mine.inner}`);
984
+ expect(resolved).not.toContain(addresses.gatekeeper.inner);
985
+ expect(resolved).not.toContain(addresses.gatekeeper.outer);
986
+ }, 120_000);
987
+ });
988
+
989
+ it('is itself hardened, seen from inside it', async () => {
990
+ expect((await inGatekeeper(['id', '-u'])).stdout).toBe('1000\n');
991
+ const status = (await inGatekeeper(['cat', '/proc/self/status'])).stdout;
992
+ expect(status).toMatch(/^NoNewPrivs:\s+1$/m);
993
+ expect(status).toMatch(/^CapEff:\s+0000000000000000$/m);
994
+ expect(status).toMatch(/^Seccomp:\s+2$/m);
995
+
996
+ const write = await inGatekeeper(['sh', '-c', 'touch /etc/openchamber-escape-probe']);
997
+ expect(write.code).not.toBe(0);
998
+ expect(write.stderr).toMatch(/Read-only file system/);
999
+ // Its own tmpfs is the one writable place, and it holds the program and nothing else.
1000
+ expect((await inGatekeeper(['sh', '-c', 'echo kept > /tmp/probe && cat /tmp/probe'])).stdout).toBe('kept\n');
1001
+ expect((await inGatekeeper(['test', '-e', '/var/run/docker.sock'])).code).not.toBe(0);
1002
+ expect((await inGatekeeper(['sh', '-c', 'ls /opt/openchamber-tools 2>&1; true'])).stdout).toMatch(/No such file/);
1003
+ }, 120_000);
1004
+ });
1005
+
1006
+ it('gets a process killed when it takes more memory than the limit, and nothing else', async () => {
1007
+ const result = await inside(['node', '-e', ALLOCATE_WITHOUT_END]);
1008
+ expect(result.code).toBe(137);
1009
+
1010
+ expect(await inside(['echo', 'alive'])).toMatchObject({ code: 0, stdout: 'alive\n' });
1011
+ expect((await place.list()).find((space) => space.id === spec.id)).toMatchObject({ state: 'running' });
1012
+ // "Nothing else" includes the server inside and its OpenCode.
1013
+ const health = await createSpaceServerChannel({ exec: place.exec }).request(spec.id, { path: '/health' });
1014
+ expect(JSON.parse(health.body)).toMatchObject({ isOpenCodeReady: true });
1015
+ expect(await place.check()).toMatchObject({ available: true });
1016
+ }, 120_000);
1017
+ });
1018
+ }