@vantran-se/openchamber-web 1.24.4 → 2.0.2

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-B82Oye3V.js} +1 -1
  5. package/dist/assets/DiffView-DJLmWn6Z.js +4 -0
  6. package/dist/assets/FilesView-DjksmZRo.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-DiUsUIgr.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-CYwWYLr3.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-CmctyRI4.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-DOHHcbgJ.js} +1 -1
  11. package/dist/assets/LinearIssuesView-DaqnylcV.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-HJNvWt7L.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-nDZRGg2I.js +12 -0
  14. package/dist/assets/OnboardingScreen-C2B5Bsof.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-C4FLBjpt.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-C_1S9c9n.js} +1 -1
  17. package/dist/assets/PlanView-_eZvb-G5.js +5 -0
  18. package/dist/assets/PluginPane-sM8fcZIV.js +3 -0
  19. package/dist/assets/SettingsView-BRfr46hb.js +29 -0
  20. package/dist/assets/SettingsWindow-CcUDwQHg.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-CrN1ZGwl.js +1 -0
  22. package/dist/assets/ToolOutputDialog-Cij4jI09.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-BXzwQ-JY.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-DSWTLJNH.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-CpUUgY8H.js} +2 -2
  26. package/dist/assets/appThemeRegistry-B5tHTkR8.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-D7D8qV8p.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-cq9uQ30i.js} +1 -1
  29. package/dist/assets/de-CUO1-KqF.js +10 -0
  30. package/dist/assets/es-BlbC6sGJ.js +16 -0
  31. package/dist/assets/fr-D2nwe_0W.js +14 -0
  32. package/dist/assets/index-BQOoYIOu.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-D0obJ2p8.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-DB3_rjyW.js} +1 -1
  37. package/dist/assets/ja-CA7vnDsZ.js +16 -0
  38. package/dist/assets/ko-Chy6a9l-.js +16 -0
  39. package/dist/assets/main-B9JFK3vF.js +896 -0
  40. package/dist/assets/main-Bhiqnrhd.js +2 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-tfqOFaBd.js} +4 -4
  42. package/dist/assets/miniChat-B0UOL5zA.js +2 -0
  43. package/dist/assets/mobile-BJppFIEo.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-Db6KuwCP.js} +3 -3
  45. package/dist/assets/pl-CTR5A_1P.js +16 -0
  46. package/dist/assets/pt-BR-BE1O1KEx.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-Yz5sfoFh.js +2 -0
  48. package/dist/assets/renderMobileApp-QQ-yB8aY.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-DKDdzRzm.js +27 -0
  50. package/dist/assets/scheduledTasksApi-C5sCuNqf.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-xURoo5t_.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-BZLGuYTU.js} +1 -1
  53. package/dist/assets/tr-Bh5vK6cW.js +7 -0
  54. package/dist/assets/uk-CLDkZFy_.js +16 -0
  55. package/dist/assets/useAppFontEffects-BxrtCnfR.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CeXuR-SR.js} +2 -2
  57. package/dist/assets/useRootScrollLock-DgWxezc1.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-DOuaQUZ0.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-63H1h21j.js} +2 -2
  64. package/dist/assets/zh-CN-DODgcmYj.js +16 -0
  65. package/dist/assets/zh-TW-ByNfIwLA.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,1391 @@
1
+ // The gatekeeper's program, run with Node on this machine, the way tools-filler.test.js runs
2
+ // the filler. Everything here is a decision the program makes, read back from its own journal.
3
+ //
4
+ // The corridor refuses every address that leads back to a machine, so a tunnel that really
5
+ // carries bytes cannot be tested from here. The escape suite does that inside real containers.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import crypto from 'node:crypto';
9
+ import http from 'node:http';
10
+ import net from 'node:net';
11
+ import os from 'node:os';
12
+ import { readFileSync } from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
17
+
18
+ const PROGRAM = fileURLToPath(new URL('./gatekeeper-program.cjs', import.meta.url));
19
+ const SECRET = 'sk-the-real-key-the-space-never-sees';
20
+ // TEST-NET-3, reserved for documentation and not on our block list, so a decision can be `allow`
21
+ // without any connection ever being made.
22
+ const PUBLIC_ADDRESS = '203.0.113.7';
23
+
24
+ /**
25
+ * The connection caps this gatekeeper runs with, and they are as small as the assertions allow.
26
+ *
27
+ * Small because a test has to exceed one, and a drop only happens when a connection is *accepted*
28
+ * past the cap: a loaded machine accepts slowly while the kernel's backlog keeps completing
29
+ * handshakes, so a flood of 300 clients against the production cap of 128 can leave every client
30
+ * connected, nothing accepted past the cap, and the flood unrecorded. Measured that way with the
31
+ * program stopped: 128 connected, no note, ever.
32
+ *
33
+ * And small because sockets are the scarce thing on a Windows host, where a closed port waits two
34
+ * minutes in TIME_WAIT before it comes back. Beside the three live Docker files, which hold the
35
+ * daemon for five to six minutes each, this file's worker died there. A flood proves "the cap
36
+ * refuses beyond it, and the refusal is noted once" at four as well as at a hundred, so the whole
37
+ * two loops that dominate this file now cost 124 sockets instead of 240: 60 for the refusals
38
+ * that must outnumber the cap, against 144, and 64 across the three floods, against 96.
39
+ *
40
+ * The corridor's is the one that cannot go lower: the program waits on up to 16 refused clients
41
+ * at once, and a cap at or under that would be filled by refusals alone, which is the opposite of
42
+ * what the test beside it proves. 20 leaves room for the handshake in flight.
43
+ *
44
+ * The production numbers, 128, 64 and 8, are in the container command, which `docker.test.js`
45
+ * asserts whole, so nothing here can raise them.
46
+ */
47
+ const CAPS = { corridor: 20, window: 4, control: 4 };
48
+
49
+ /** An address of this machine that is not loopback, as a container's own address is not. */
50
+ const ownAddress = () => Object.values(os.networkInterfaces())
51
+ .flat()
52
+ .filter(Boolean)
53
+ .map((entry) => entry.address)
54
+ .find((address) => net.isIPv4(address) && !address.startsWith('127.')) ?? '127.0.0.1';
55
+
56
+ const freePort = () => new Promise((resolve) => {
57
+ const server = net.createServer();
58
+ server.listen(0, '127.0.0.1', () => {
59
+ const { port } = server.address();
60
+ server.close(() => resolve(port));
61
+ });
62
+ });
63
+
64
+ describe('gatekeeper program', () => {
65
+ let child;
66
+ let ports;
67
+ let upstream;
68
+ let upstreamPort;
69
+ let seen = [];
70
+ let childOutput = '';
71
+
72
+ // Every socket and every request this file opens, so that a test that throws still leaves the
73
+ // worker with nothing running. A leaked socket is how a test file takes a vitest worker down,
74
+ // and this file opens hundreds of them on purpose.
75
+ const opened = new Set();
76
+ const track = (thing) => {
77
+ opened.add(thing);
78
+ thing.on('close', () => opened.delete(thing));
79
+ return thing;
80
+ };
81
+
82
+ const closeEverything = () => {
83
+ for (const thing of opened) {
84
+ try {
85
+ if (thing.destroy) thing.destroy();
86
+ else if (thing.abort) thing.abort();
87
+ } catch {
88
+ // Already gone.
89
+ }
90
+ }
91
+ opened.clear();
92
+ };
93
+
94
+ afterEach(() => {
95
+ closeEverything();
96
+ });
97
+
98
+ /**
99
+ * One request to the control channel. It never rejects: a rejection that lands after its test
100
+ * has finished is an unhandled rejection, which ends the worker rather than the test.
101
+ */
102
+ const control = (method, path, body) => new Promise((resolve) => {
103
+ const request = track(http.request({ host: '127.0.0.1', port: ports.control, method, path }, (answer) => {
104
+ let text = '';
105
+ answer.on('data', (chunk) => { text += chunk; });
106
+ answer.on('end', () => resolve({ status: answer.statusCode, body: text }));
107
+ answer.on('error', () => resolve({ status: 0, body: '' }));
108
+ }));
109
+ request.on('error', () => resolve({ status: 0, body: '' }));
110
+ request.end(body === undefined ? undefined : JSON.stringify(body));
111
+ });
112
+
113
+ const journal = async () => JSON.parse((await control('GET', '/journal')).body);
114
+
115
+ /**
116
+ * Where the journal stands now, counted in records ever written: what the ring holds plus what
117
+ * has already fallen out of it. Monotonic, and with no clock in it anywhere.
118
+ *
119
+ * A time was the obvious thing to mark a test's place with and it was the wrong thing, because
120
+ * the two clocks are not one clock: the test reads its own process's, the record carries the
121
+ * program's, and neither is better than the platform's timer. On Windows that tick is about
122
+ * 15 ms, which is an age next to a refusal that needs no name resolved and nothing connected,
123
+ * so a record written after the mark can carry a stamp from before it and be filtered away as
124
+ * somebody else's. Seven tests failed that way there, every one of them an instant decision,
125
+ * while every test whose decision took real time passed. A count cannot do that on any machine.
126
+ */
127
+ const journalMark = async () => { const { records, dropped } = await journal(); return records.length + dropped; };
128
+
129
+ /**
130
+ * The last decision about this destination among the records written since `mark`. It waits for
131
+ * the record, because the program journals a decision when it has made up its mind and not when
132
+ * the client hears about it. The mark is what keeps an earlier test's record out of the answer,
133
+ * and it has to: `127.0.0.1`, `example.com` and `blocked.test` are each decided about many
134
+ * times in this file, and an assertion that reads the wrong one passes for the wrong reason.
135
+ */
136
+ const recordsSince = async (mark) => {
137
+ const { records, dropped } = await journal();
138
+ // The ring is in order, so the records written since the mark are the last of it. More than
139
+ // the ring holds and this is every record there is, which is the most that can be said.
140
+ const since = Math.max(0, records.length + dropped - mark);
141
+ return records.slice(Math.max(0, records.length - since));
142
+ };
143
+
144
+ /** Every note of this kind written since the mark, whatever listener made it. */
145
+ const notesSince = async (decision, mark) => (await recordsSince(mark)).filter((entry) => entry.decision === decision);
146
+
147
+ /**
148
+ * A note of this kind from this listener, written since the mark. Waited for, like every other
149
+ * record this file reads: a flood is noted when the program drops a connection, which is not
150
+ * the moment the client's socket reported itself connected.
151
+ */
152
+ const noteFor = async (listener, decision, mark) => {
153
+ for (let attempt = 0; attempt < 120; attempt += 1) {
154
+ const found = (await notesSince(decision, mark)).filter((entry) => entry.listener === listener);
155
+ if (found.length > 0) return found.at(-1);
156
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
157
+ }
158
+ throw new Error(`the gatekeeper never noted ${decision} on the ${listener} after its record ${mark}`);
159
+ };
160
+
161
+ const decisionFor = async (host, mark = 0) => {
162
+ for (let attempt = 0; attempt < 120; attempt += 1) {
163
+ const fresh = (await recordsSince(mark)).filter((entry) => entry.host === host);
164
+ if (fresh.length > 0) return fresh.at(-1);
165
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
166
+ }
167
+ throw new Error(`the gatekeeper never decided anything about ${host} after its record ${mark}`);
168
+ };
169
+
170
+ /** A raw CONNECT, as any client in the space would send it. Resolves the status line. */
171
+ const connect = (line, { keepOpen = false, wait = 5_000 } = {}) => new Promise((resolve) => {
172
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor }));
173
+ let answer = '';
174
+ const done = (text) => {
175
+ if (!keepOpen) socket.destroy();
176
+ resolve({ status: text, socket });
177
+ };
178
+ socket.on('connect', () => socket.write(`${line}\r\n\r\n`));
179
+ socket.on('data', (chunk) => {
180
+ answer += chunk;
181
+ if (answer.includes('\r\n')) done(answer.split('\r\n')[0]);
182
+ });
183
+ socket.on('error', () => done('socket error'));
184
+ socket.on('close', () => done(answer.split('\r\n')[0] ?? ''));
185
+ // A target the program allows is never answered, because nothing is there to connect to.
186
+ setTimeout(() => done('no answer'), wait);
187
+ });
188
+
189
+ const tunnelTo = (target) => connect(`CONNECT ${target} HTTP/1.1`);
190
+
191
+ /** The whole answer to a CONNECT the corridor refuses, headers included. */
192
+ const connectFully = (line) => new Promise((resolve) => {
193
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor }));
194
+ let answer = '';
195
+ const done = () => { socket.destroy(); resolve(answer); };
196
+ socket.on('connect', () => socket.write(`${line}\r\n\r\n`));
197
+ socket.on('data', (chunk) => { answer += chunk; });
198
+ socket.on('error', done);
199
+ socket.on('close', done);
200
+ setTimeout(done, 5_000);
201
+ });
202
+
203
+ /** A plain proxy request, the form the corridor refuses. Its target rides in the request line. */
204
+ const throughPlainProxy = (url) => new Promise((resolve) => {
205
+ const request = track(http.request({ host: '127.0.0.1', port: ports.corridor, method: 'GET', path: url }, (answer) => {
206
+ answer.resume();
207
+ answer.on('end', () => resolve({ status: answer.statusCode }));
208
+ answer.on('error', () => resolve({ status: 0 }));
209
+ }));
210
+ request.on('error', () => resolve({ status: 0 }));
211
+ request.end();
212
+ });
213
+
214
+ /** One request through the window, as a tool in the space would send it. */
215
+ const throughWindow = (path, headers = {}) => new Promise((resolve) => {
216
+ const request = track(http.request({ host: '127.0.0.1', port: ports.window, path, headers }, (answer) => {
217
+ let text = '';
218
+ answer.on('data', (chunk) => { text += chunk; });
219
+ answer.on('end', () => resolve({ status: answer.statusCode, body: text }));
220
+ answer.on('error', () => resolve({ status: 0, body: '' }));
221
+ }));
222
+ request.on('error', () => resolve({ status: 0, body: '' }));
223
+ request.end();
224
+ });
225
+
226
+ beforeAll(async () => {
227
+ ports = { corridor: await freePort(), window: await freePort(), control: await freePort() };
228
+ // The upstream of a grant. It answers with what it received, minus the secret itself.
229
+ upstream = http.createServer((request, response) => {
230
+ seen.push({ url: request.url, headers: request.headers });
231
+ response.writeHead(200, { 'content-type': 'application/json' });
232
+ response.end(JSON.stringify({ upstreamSaw: request.url }));
233
+ });
234
+ upstreamPort = await freePort();
235
+ await new Promise((resolve) => { upstream.listen(upstreamPort, '127.0.0.1', resolve); });
236
+
237
+ // The last argument is the window's deadline: 1.5 seconds here so a test can watch it
238
+ // pass, 300000 in the container command, which the hardening test asserts.
239
+ child = spawn(process.execPath, [PROGRAM, '127.0.0.1', String(ports.corridor), String(ports.window), String(ports.control), '1500', String(CAPS.corridor), String(CAPS.window), String(CAPS.control)], { stdio: ['ignore', 'pipe', 'pipe'] });
240
+ // Both pipes are read, always. A full stderr pipe blocks the child, and the program writes
241
+ // there whenever it keeps running after an error, which the flood tests provoke.
242
+ const keep = (chunk) => { childOutput = `${childOutput}${chunk}`.slice(-4_000); };
243
+ child.stdout.on('data', keep);
244
+ child.stderr.on('data', keep);
245
+ await new Promise((resolve, reject) => {
246
+ const ready = (chunk) => { if (String(chunk).includes('gatekeeper:')) resolve(); };
247
+ child.stdout.on('data', ready);
248
+ child.once('exit', (code) => reject(new Error(`the gatekeeper ended with ${code}: ${childOutput}`)));
249
+ setTimeout(() => reject(new Error(`the gatekeeper did not start: ${childOutput}`)), 10_000);
250
+ });
251
+ }, 20_000);
252
+
253
+ afterAll(async () => {
254
+ closeEverything();
255
+ if (child && child.exitCode === null) {
256
+ const ended = new Promise((resolve) => { child.once('exit', resolve); setTimeout(resolve, 5_000); });
257
+ child.kill('SIGKILL');
258
+ await ended;
259
+ }
260
+ // A server with a connection still on it does not close, and a hook that never returns is
261
+ // the other way a worker dies. So the connections go first, and the wait has an end.
262
+ upstream?.closeAllConnections?.();
263
+ await new Promise((resolve) => {
264
+ const done = setTimeout(resolve, 5_000);
265
+ upstream?.close(() => { clearTimeout(done); resolve(); });
266
+ });
267
+ });
268
+
269
+ describe('corridor, in allowlist mode', () => {
270
+ beforeAll(async () => {
271
+ expect((await control('POST', '/network', { mode: 'allowlist', domains: ['example.com', 'blocked.test'] })).status).toBe(200);
272
+ });
273
+
274
+ it('resolves a name itself, and refuses it when nothing comes back', async () => {
275
+ // `.test` never resolves. The list rules are covered directly, further down.
276
+ expect((await tunnelTo('blocked.test:443')).status).toMatch(/403/);
277
+ expect(await decisionFor('blocked.test')).toMatchObject({ listener: 'corridor', port: 443, decision: expect.stringMatching(/^deny:unresolved:/) });
278
+ });
279
+
280
+ it('refuses a name that is not on the list, with no suffix or wildcard matching', async () => {
281
+ for (const name of ['sub.example.com', 'notexample.com', 'example.com.evil.test', 'example.co']) {
282
+ expect((await tunnelTo(`${name}:443`)).status).toMatch(/403/);
283
+ expect(await decisionFor(name)).toMatchObject({ decision: 'deny:not-on-allowlist' });
284
+ }
285
+ });
286
+
287
+ it('reads a name in any case and with a trailing dot as the same name', async () => {
288
+ for (const written of ['EXAMPLE.COM', 'example.com.']) {
289
+ const at = await journalMark();
290
+ await tunnelTo(`${written}:443`);
291
+ expect((await decisionFor('example.com', at)).decision).not.toBe('deny:not-on-allowlist');
292
+ }
293
+ });
294
+
295
+ it('allows port 443 only', async () => {
296
+ for (const port of [80, 22, 8080, 3128]) {
297
+ const at = await journalMark();
298
+ expect((await tunnelTo(`example.com:${port}`)).status).toMatch(/403/);
299
+ expect(await decisionFor('example.com', at)).toMatchObject({ port, decision: 'deny:port' });
300
+ }
301
+ });
302
+ });
303
+
304
+ describe('corridor, in open mode', () => {
305
+ beforeAll(async () => {
306
+ expect((await control('POST', '/network', { mode: 'open', domains: [] })).status).toBe(200);
307
+ });
308
+
309
+ it('tells the space refused from unreachable, and nothing else about why', async () => {
310
+ // A bare 502 reads like a broken allowlist, so the two are told apart. The reason why it
311
+ // was refused is not: sorting `blocked-address` from `unresolved` would let an agent map
312
+ // the user's internal DNS from inside, and the mode it is in from the other reasons.
313
+ const anAddress = await connectFully('CONNECT 203.0.113.7:443 HTTP/1.1');
314
+ const notOnTheList = await connectFully('CONNECT some.example.org:443 HTTP/1.1');
315
+ const doesNotResolve = await connectFully('CONNECT blocked.test:443 HTTP/1.1');
316
+
317
+ for (const answer of [anAddress, notOnTheList, doesNotResolve]) {
318
+ expect(answer).toMatch(/^HTTP\/1\.1 403 /);
319
+ expect(answer).toContain('x-gatekeeper-reason: refused');
320
+ }
321
+ // Every refusal reads the same from inside, whatever the journal says about it.
322
+ expect(new Set([anAddress, notOnTheList, doesNotResolve]).size).toBe(1);
323
+ for (const leak of ['not-a-name', 'blocked-address', 'unresolved', 'allowlist', 'ENOTFOUND']) {
324
+ expect(anAddress + notOnTheList + doesNotResolve).not.toContain(leak);
325
+ }
326
+ // And the journal still holds the reason, which is where the user reads it.
327
+ expect((await decisionFor('blocked.test')).decision).toMatch(/^deny:unresolved:/);
328
+ });
329
+
330
+ it('resolves the name itself before it decides anything about the address', async () => {
331
+ // The two halves of the rebinding defence are covered apart: that a name passes the name
332
+ // rules and is then resolved, and that the resolved address is judged. Both are below,
333
+ // against the rules directly. The join, a public name that resolves to 127.0.0.1, needs a
334
+ // resolver that answers, so it lives in the escape suite with `localtest.me`.
335
+ const at = await journalMark();
336
+ await tunnelTo('blocked.test:443');
337
+ expect(await decisionFor('blocked.test', at)).toMatchObject({ decision: expect.stringMatching(/^deny:unresolved:/) });
338
+ });
339
+
340
+ it('allows port 443 only here too, so a space can attack nothing else from the user\'s address', async () => {
341
+ // `.test` never resolves, so 443 comes back as the decision after the name and port rules
342
+ // have passed. Every other port is refused before any of that.
343
+ const at = await journalMark();
344
+ await tunnelTo('blocked.test:443');
345
+ expect(await decisionFor('blocked.test', at)).toMatchObject({ port: 443, decision: expect.stringMatching(/^deny:unresolved:/) });
346
+
347
+ for (const port of [80, 22, 8443, 9418]) {
348
+ const when = await journalMark();
349
+ expect((await tunnelTo(`blocked.test:${port}`)).status).toMatch(/403/);
350
+ expect(await decisionFor('blocked.test', when)).toMatchObject({ port, decision: 'deny:port' });
351
+ }
352
+ });
353
+
354
+ // The escape the reviewer found: `auth.openai.com` was refused and its address was not, and a
355
+ // space can learn that address from a DNS-over-HTTPS resolver through this same corridor.
356
+ it.each([
357
+ ['an IPv4 literal', '104.18.41.241:443'],
358
+ ['an IPv4 literal on another port, refused before the port rule', '104.18.41.241:8443'],
359
+ ['an IPv4-mapped IPv6 literal', '[::ffff:104.18.41.241]:443'],
360
+ ['an IPv4-mapped literal in hex', '[::ffff:6812:29f1]:443'],
361
+ ['a bracketed IPv6 literal', '[2606:4700::6812:29f1]:443'],
362
+ ['a NAT64 literal', '[64:ff9b::6812:29f1]:443'],
363
+ ['a 6to4 literal', '[2002:6812:29f1::1]:443'],
364
+ ['a documentation address', `${PUBLIC_ADDRESS}:443`],
365
+ // Everything below is an address to `getaddrinfo` and was not one to this program.
366
+ // The first of them carried a real TLS session to the refused host in the first draft.
367
+ ['the same address as one integer', '1746020849:443'],
368
+ ['another address as one integer', '16843009:443'],
369
+ ['an address in hex', '0x01010101:443'],
370
+ ['an address in three parts', '1.1.257:443'],
371
+ ['an address in four hex parts', '0x1.0x1.0x1.0x1:443'],
372
+ ['an address with an octal part', '0177.0.0.1:443'],
373
+ ['a name with no dot at all', 'localhost:443'],
374
+ ])('refuses %s, because the corridor takes names only', async (title, target) => {
375
+ const at = await journalMark();
376
+ const answer = await tunnelTo(target);
377
+ const host = target.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
378
+ expect(answer.status).toMatch(/403/);
379
+ expect((await decisionFor(host, at)).decision).toBe('deny:not-a-name');
380
+ });
381
+
382
+ // `auth.openai.com。` and its two siblings resolve on this C library exactly like the name they
383
+ // imitate. They never reach the corridor's rules, because Node's parser refuses a non-ASCII
384
+ // request target first, so this test says that plainly and then checks the rule itself, which
385
+ // is what would have to hold if a client ever got such a target past the parser.
386
+ it.each([
387
+ ['an ideographic full stop', 'auth.openai.com\u3002'],
388
+ ['a fullwidth full stop', 'auth.openai\uff0ecom'],
389
+ ['a halfwidth ideographic full stop', 'auth\uff61openai.com'],
390
+ ])('never lets a name with %s reach the corridor, and refuses it by rule as well', async (title, host) => {
391
+ const answer = await tunnelTo(`${host}:443`);
392
+ expect(answer.status).toMatch(/400|socket error|closed/);
393
+
394
+ const { refuseTarget, setNetwork } = createRequire(import.meta.url)(PROGRAM);
395
+ setNetwork({ mode: 'open', domains: [] });
396
+ expect(refuseTarget(host, 443)).toBe('deny:not-a-name');
397
+ });
398
+
399
+ it('records that the parser threw something out, so probing with such targets is not invisible', async () => {
400
+ await tunnelTo('auth.openai.com\u3002:443');
401
+ // Waited for, not read once: the program journals when it decides. The note is not asked to
402
+ // be this attempt's own, because notes are rate-limited to one a minute per listener and
403
+ // per kind, and an earlier malformed line in this file may already have spent the minute.
404
+ // That is the limiter working, and it is why this is one test and not three. What this
405
+ // proves is that the parser's refusals reach the journal at all, so a space cannot probe
406
+ // with targets the parser throws out and leave the user's view blind by construction.
407
+ expect(await noteFor('corridor', 'deny:unreadable-request', 0)).toMatchObject({ host: '', port: 0 });
408
+ });
409
+
410
+ it('refuses auth.openai.com, whatever the mode and whatever the list says', async () => {
411
+ await tunnelTo('auth.openai.com:443');
412
+ expect(await decisionFor('auth.openai.com')).toMatchObject({ decision: 'deny:always-refused' });
413
+
414
+ const at = await journalMark();
415
+ await control('POST', '/network', { mode: 'allowlist', domains: ['auth.openai.com', 'example.com'] });
416
+ await tunnelTo('auth.openai.com:443');
417
+ expect((await decisionFor('auth.openai.com', at)).decision).toBe('deny:always-refused');
418
+ await control('POST', '/network', { mode: 'open', domains: [] });
419
+ });
420
+
421
+ it('takes CONNECT only, and keeps the path of a plain proxy request out of its journal', async () => {
422
+ const at = await journalMark();
423
+ const answer = await throughPlainProxy(`http://${PUBLIC_ADDRESS}/secret/path?token=leak`);
424
+ expect(answer.status).toBe(403);
425
+ const record = await decisionFor(PUBLIC_ADDRESS, at);
426
+ expect(record).toMatchObject({ listener: 'corridor', host: PUBLIC_ADDRESS, decision: 'deny:not-connect' });
427
+ expect(JSON.stringify(record)).not.toContain('secret');
428
+ expect(JSON.stringify(record)).not.toContain('token');
429
+ });
430
+
431
+ });
432
+
433
+ describe('corridor, under abuse', () => {
434
+ it.each([
435
+ 'CONNECT HTTP/1.1',
436
+ 'CONNECT :443 HTTP/1.1',
437
+ 'CONNECT example.com: HTTP/1.1',
438
+ 'CONNECT example.com:99999 HTTP/1.1',
439
+ 'CONNECT example.com:-1 HTTP/1.1',
440
+ 'CONNECT http://example.com/path:443 HTTP/1.1',
441
+ `CONNECT ${'x'.repeat(400)}:443 HTTP/1.1`,
442
+ 'CONNECT [::1:443 HTTP/1.1',
443
+ 'GARBAGE',
444
+ 'CONNECT example.com:443 NOT-HTTP',
445
+ ])('answers %j without ending', async (line) => {
446
+ await connect(line, { wait: 500 });
447
+ expect(child.exitCode).toBe(null);
448
+ });
449
+
450
+ it('survives a client that resets in the middle of the handshake', async () => {
451
+ for (let attempt = 0; attempt < 20; attempt += 1) {
452
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor }));
453
+ socket.on('error', () => {});
454
+ socket.on('connect', () => { socket.write('CONNECT example.com:4'); socket.resetAndDestroy(); });
455
+ }
456
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
457
+ expect(child.exitCode).toBe(null);
458
+ });
459
+
460
+ it('survives a huge header block and a slow client', async () => {
461
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor }));
462
+ socket.on('error', () => {});
463
+ await new Promise((resolve) => { socket.on('connect', resolve); });
464
+ socket.write(`CONNECT ${PUBLIC_ADDRESS}:443 HTTP/1.1\r\n`);
465
+ socket.write(`X-Filler: ${'a'.repeat(200_000)}\r\n\r\n`);
466
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
467
+ socket.destroy();
468
+ expect(child.exitCode).toBe(null);
469
+ });
470
+
471
+ // The tunnel cap itself needs 64 tunnels that were allowed, which needs names that resolve
472
+ // to public addresses. The escape suite does that inside a space with a real allowed domain.
473
+ // What is tested here is the cap that holds when nothing is allowed at all: sockets.
474
+
475
+ // The cap only bounds what it can see. A refused client used to keep its socket for as long
476
+ // as it liked: the socket allows half-open, the request timeouts stop applying once a CONNECT
477
+ // has been handed over, and no tunnel exists to carry an idle limit. 128 refusals and the
478
+ // corridor answered nothing, with nothing open anywhere.
479
+ it('lets a refused socket go, so refusals cannot fill the connection cap', async () => {
480
+ // One at a time, and each client keeps its socket after reading the refusal. With the
481
+ // socket released at our end, every one of them is answered however long the clients hold
482
+ // on. Without it the cap fills — 128 in the container, 20 here — and everything after that
483
+ // is dropped, the corridor with it. Three times the cap, which is past the cap and past the
484
+ // 16 refused clients the program waits on at once, with room to spare and no more sockets
485
+ // than that needs: this loop was the most expensive thing in the file.
486
+ const attempts = CAPS.corridor * 3;
487
+ const kept = [];
488
+ const outcomes = [];
489
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
490
+ outcomes.push(await new Promise((resolve) => {
491
+ // `allowHalfOpen`, so reading the refusal does not make this client close by itself.
492
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor, allowHalfOpen: true }));
493
+ kept.push(socket);
494
+ socket.on('error', () => resolve('error'));
495
+ socket.on('connect', () => socket.write('CONNECT 203.0.113.7:443 HTTP/1.1\r\n\r\n'));
496
+ socket.on('data', () => resolve('refused'));
497
+ setTimeout(() => resolve('no answer'), 5_000);
498
+ }));
499
+ }
500
+ expect(outcomes.filter((outcome) => outcome === 'refused')).toHaveLength(attempts);
501
+
502
+ // And the corridor is still there, with nothing of its own held open.
503
+ expect((await tunnelTo('blocked.test:443')).status).toMatch(/403/);
504
+ expect(JSON.parse((await control('GET', '/health')).body)).toMatchObject({ ready: true });
505
+ expect(child.exitCode).toBe(null);
506
+ for (const socket of kept) socket.destroy();
507
+ }, 60_000);
508
+
509
+ it('keeps the refusal readable for a client that is still writing, and for one reading slowly', async () => {
510
+ // Letting the socket go is what stopped the wedge above, and closing it while the client is
511
+ // still talking costs this: a close with unread bytes in the receive queue is a reset, and a
512
+ // reset throws away what the client has received and not yet read. Measured against a build
513
+ // that closed at once: a client that wrote a megabyte after its CONNECT lost its 403 in
514
+ // three runs out of five. The refusal stood either way; the reason header did not.
515
+ const socket = track(net.connect({ host: '127.0.0.1', port: ports.corridor, allowHalfOpen: true }));
516
+ let text = '';
517
+ socket.on('error', () => {});
518
+ socket.on('data', (chunk) => { text += chunk; });
519
+ await new Promise((resolve) => { socket.on('connect', resolve); });
520
+ socket.write('CONNECT 203.0.113.7:443 HTTP/1.1\r\n\r\n');
521
+ socket.write('x'.repeat(4 * 1024 * 1024));
522
+ const arrived = Date.now();
523
+ while (!text.includes('\r\n\r\n') && Date.now() - arrived < 10_000) {
524
+ await new Promise((resolve) => { setTimeout(resolve, 20); });
525
+ }
526
+ expect(text).toContain('403');
527
+ expect(text).toContain('x-gatekeeper-reason: refused');
528
+
529
+ // The discriminating part: the corridor is still holding this socket open, because the
530
+ // client has not finished. A build that closed as soon as it had written the refusal fails
531
+ // here every time, and the megabyte case above only fails on it sometimes.
532
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
533
+ expect(socket.destroyed).toBe(false);
534
+
535
+ // And it goes as soon as the client is done with it, which is what keeps refusals off the
536
+ // connection cap. Only the last few refusals are ever waited on; the test above holds 140.
537
+ const closed = new Promise((resolve) => { socket.on('close', resolve); setTimeout(resolve, 5_000); });
538
+ socket.end();
539
+ await closed;
540
+ expect(socket.destroyed).toBe(true);
541
+
542
+ // A client that takes its answer a byte at a time still gets all of it.
543
+ const slow = await new Promise((resolve) => {
544
+ const reader = track(net.connect({ host: '127.0.0.1', port: ports.corridor, allowHalfOpen: true }));
545
+ let seen = '';
546
+ reader.on('error', () => resolve(seen));
547
+ reader.on('connect', () => {
548
+ reader.pause();
549
+ reader.write('CONNECT 203.0.113.7:443 HTTP/1.1\r\n\r\n');
550
+ const timer = setInterval(() => {
551
+ const byte = reader.read(1);
552
+ if (byte) seen += byte;
553
+ if (seen.includes('\r\n\r\n')) { clearInterval(timer); resolve(seen); }
554
+ }, 10);
555
+ reader.on('close', () => { clearInterval(timer); resolve(seen); });
556
+ });
557
+ setTimeout(() => resolve(seen), 10_000);
558
+ });
559
+ expect(slow).toContain('403');
560
+ expect(slow).toContain('x-gatekeeper-reason: refused');
561
+ expect(slow.endsWith('\r\n\r\n')).toBe(true);
562
+ }, 30_000);
563
+
564
+ // A cap is not exceeded by opening sockets quickly. A connection is dropped when it is
565
+ // *accepted* past the cap, and accepting is the server's turn on the CPU: on a busy machine
566
+ // the kernel's backlog keeps completing handshakes while the process is not scheduled, so
567
+ // every client can be connected with nothing accepted past the cap and nothing to record.
568
+ // Measured with the program stopped while 300 clients connected: 128 connected, no note,
569
+ // ever, and no amount of waiting would have produced one. That is what failed in CI and then
570
+ // on Windows beside the live Docker files, and lowering the cap did not fix it, because the
571
+ // problem was never how many sockets the client opens.
572
+ //
573
+ // So these tests climb to the cap instead of flooding it, and every rung is *proven* accepted
574
+ // before the next one opens: a listener only answers a connection it has accepted. Once the
575
+ // cap is full of proven connections, the next one can only be dropped, whatever else the
576
+ // machine is doing. A second gatekeeper runs with a cap of 2 on every listener, so a whole
577
+ // test costs five sockets instead of forty; the first one keeps a corridor cap above the 16
578
+ // refused clients the program waits on at once, which is what the test above it needs.
579
+ describe('at a cap it cannot exceed', () => {
580
+ let small;
581
+ let smallPorts;
582
+
583
+ /** One request, on a connection of its own, closed by its answer. Never keeps a slot. */
584
+ const ask = (port, path) => new Promise((resolve) => {
585
+ const request = track(http.request({ host: '127.0.0.1', port, path, agent: false }, (answer) => {
586
+ let text = '';
587
+ answer.on('data', (chunk) => { text += chunk; });
588
+ answer.on('end', () => resolve({ status: answer.statusCode, body: text }));
589
+ }));
590
+ request.on('error', () => resolve({ status: 0, body: '' }));
591
+ request.end();
592
+ });
593
+
594
+ const smallNotes = async (listener) => {
595
+ const body = (await ask(smallPorts.control, '/journal')).body || '{"records":[]}';
596
+ return JSON.parse(body).records.filter((entry) => entry.decision === 'deny:too-many-connections' && entry.listener === listener);
597
+ };
598
+
599
+ /**
600
+ * A connection that has been accepted, proven by the listener answering on it, and then
601
+ * kept. What comes back does not matter, only that it came: nothing is answered on a
602
+ * connection that was never accepted.
603
+ */
604
+ const accepted = (port, line) => new Promise((resolve, reject) => {
605
+ // `allowHalfOpen`, or the corridor's rungs do not hold: a refusal ends the gatekeeper's
606
+ // side of the socket, and a client without it ends its own side in answer and the
607
+ // connection is gone. Measured: two refusals left nothing behind and nothing was dropped.
608
+ const socket = track(net.connect({ host: '127.0.0.1', port, allowHalfOpen: true }));
609
+ socket.on('error', reject);
610
+ socket.on('connect', () => socket.write(line));
611
+ socket.on('data', () => resolve(socket));
612
+ setTimeout(() => reject(new Error(`nothing answered on port ${port}`)), 10_000);
613
+ });
614
+
615
+ /**
616
+ * A connection the listener drops: closed without a byte on it, because the cap was full of
617
+ * connections this test had already watched being answered. The close is the observable,
618
+ * and the note is written before it, so nothing here waits on a schedule.
619
+ */
620
+ const dropped = (port) => new Promise((resolve, reject) => {
621
+ const socket = track(net.connect({ host: '127.0.0.1', port }));
622
+ let answered = false;
623
+ socket.on('data', () => { answered = true; });
624
+ socket.on('error', () => resolve('closed'));
625
+ socket.on('close', () => resolve(answered ? 'answered' : 'closed'));
626
+ setTimeout(() => reject(new Error(`the listener on port ${port} neither answered nor let go`)), 10_000);
627
+ });
628
+
629
+ const settle = () => new Promise((resolve) => { setTimeout(resolve, 200); });
630
+
631
+ beforeAll(async () => {
632
+ smallPorts = { corridor: await freePort(), window: await freePort(), control: await freePort() };
633
+ small = spawn(process.execPath, [PROGRAM, '127.0.0.1', String(smallPorts.corridor), String(smallPorts.window), String(smallPorts.control), '1500', '2', '2', '2'], { stdio: ['ignore', 'pipe', 'pipe'] });
634
+ small.stdout.resume();
635
+ small.stderr.resume();
636
+ const ready = Date.now();
637
+ while (Date.now() - ready < 20_000) {
638
+ if ((await ask(smallPorts.control, '/health')).status === 200) return;
639
+ await settle();
640
+ }
641
+ throw new Error('the second gatekeeper never became ready');
642
+ }, 30_000);
643
+
644
+ afterAll(async () => {
645
+ if (!small) return;
646
+ const ended = new Promise((resolve) => { small.once('exit', resolve); setTimeout(resolve, 5_000); });
647
+ small.kill('SIGKILL');
648
+ await ended;
649
+ });
650
+
651
+ it('refuses a connection past the corridor cap, and notes it once', async () => {
652
+ // Two refusals, each proven accepted by its own 403 and each kept: a refused client's
653
+ // socket is held until the client is done with it, so these two fill a cap of 2.
654
+ const held = [await accepted(smallPorts.corridor, 'CONNECT blocked.test:443 HTTP/1.1\r\n\r\n'), await accepted(smallPorts.corridor, 'CONNECT blocked.test:443 HTTP/1.1\r\n\r\n')];
655
+ try {
656
+ // Three more, one at a time, and every one of them is dropped.
657
+ for (let attempt = 0; attempt < 3; attempt += 1) {
658
+ expect(await dropped(smallPorts.corridor), 'a connection past the cap was served').toBe('closed');
659
+ }
660
+ // Recorded, and recorded once for three drops: five hundred notes would erase
661
+ // everything else the space did.
662
+ const notes = await smallNotes('corridor');
663
+ expect(notes).toHaveLength(1);
664
+ expect(notes[0]).toMatchObject({ host: '', port: 0 });
665
+ // The process is alive, and the journal, which lives in its memory, is readable.
666
+ expect(small.exitCode).toBe(null);
667
+ } finally {
668
+ for (const socket of held) socket.destroy();
669
+ }
670
+ // And it serves again once those connections let go.
671
+ await settle();
672
+ expect(await accepted(smallPorts.corridor, 'CONNECT blocked.test:443 HTTP/1.1\r\n\r\n')).toBeTruthy();
673
+ }, 60_000);
674
+
675
+ it('refuses a connection past the window cap, and notes it once', async () => {
676
+ const held = [await accepted(smallPorts.window, 'GET /model/nothing/here HTTP/1.1\r\nHost: gatekeeper\r\n\r\n'), await accepted(smallPorts.window, 'GET /model/nothing/here HTTP/1.1\r\nHost: gatekeeper\r\n\r\n')];
677
+ try {
678
+ for (let attempt = 0; attempt < 3; attempt += 1) {
679
+ expect(await dropped(smallPorts.window), 'a connection past the cap was served').toBe('closed');
680
+ }
681
+ expect(await smallNotes('window')).toHaveLength(1);
682
+ // One listener's cap is not another's: the corridor is untouched by a full window.
683
+ const onTheCorridor = await accepted(smallPorts.corridor, 'CONNECT blocked.test:443 HTTP/1.1\r\n\r\n');
684
+ onTheCorridor.destroy();
685
+ expect(small.exitCode).toBe(null);
686
+ } finally {
687
+ for (const socket of held) socket.destroy();
688
+ }
689
+ }, 60_000);
690
+
691
+ it('refuses a connection past the control cap, and answers again afterwards', async () => {
692
+ const held = [await accepted(smallPorts.control, 'GET /health HTTP/1.1\r\nHost: gatekeeper\r\n\r\n'), await accepted(smallPorts.control, 'GET /health HTTP/1.1\r\nHost: gatekeeper\r\n\r\n')];
693
+ try {
694
+ for (let attempt = 0; attempt < 3; attempt += 1) {
695
+ expect(await dropped(smallPorts.control), 'a connection past the cap was served').toBe('closed');
696
+ }
697
+ expect(small.exitCode).toBe(null);
698
+ } finally {
699
+ for (const socket of held) socket.destroy();
700
+ }
701
+ // Read after they let go, because reading the journal needs the channel this test fills.
702
+ // The host is the only client of that channel, which is why its cap is the smallest.
703
+ await settle();
704
+ expect((await ask(smallPorts.control, '/health')).status).toBe(200);
705
+ expect(await smallNotes('control')).toHaveLength(1);
706
+ }, 60_000);
707
+ });
708
+
709
+ it('is still healthy afterwards', async () => {
710
+ expect((await control('GET', '/health')).status).toBe(200);
711
+ expect(child.exitCode).toBe(null);
712
+ });
713
+ });
714
+
715
+ describe('window', () => {
716
+ beforeAll(async () => {
717
+ seen = [];
718
+ expect((await control('POST', '/grants', { id: 'anthropic', upstream: `http://127.0.0.1:${upstreamPort}/v1`, header: 'x-api-key', secret: SECRET })).status).toBe(200);
719
+ expect((await control('POST', '/grants', { id: 'openai', upstream: `http://127.0.0.1:${upstreamPort}/v1`, header: 'authorization', secret: SECRET })).status).toBe(200);
720
+ });
721
+
722
+ it('refuses an unknown grant id', async () => {
723
+ const answer = await throughWindow('/model/nothing-here/v1/messages');
724
+ expect(answer.status).toBe(403);
725
+ expect(await decisionFor('unknown-grant')).toMatchObject({ listener: 'window', decision: 'deny:no-grant' });
726
+ });
727
+
728
+ it('refuses a path that is not a grant', async () => {
729
+ expect((await throughWindow('/')).status).toBe(403);
730
+ expect((await throughWindow('/model/')).status).toBe(403);
731
+ expect((await throughWindow('/../model/anthropic')).status).toBe(403);
732
+ });
733
+
734
+ it('adds the grant\'s own header and takes away what the space sent', async () => {
735
+ const answer = await throughWindow('/model/anthropic/messages?stream=true', {
736
+ authorization: 'Bearer sk-fake-inside',
737
+ 'x-api-key': 'sk-fake-inside',
738
+ 'x-keep-me': 'yes',
739
+ });
740
+
741
+ expect(answer.status).toBe(200);
742
+ const request = seen.at(-1);
743
+ expect(request.url).toBe('/v1/messages?stream=true');
744
+ expect(request.headers['x-api-key']).toBe(SECRET);
745
+ expect(request.headers.authorization).toBeUndefined();
746
+ expect(request.headers['x-keep-me']).toBe('yes');
747
+ // The answer goes back to the space, so it must not carry the key.
748
+ expect(answer.body).not.toContain(SECRET);
749
+ });
750
+
751
+ it('sends a bearer token for an OpenAI-style grant', async () => {
752
+ await throughWindow('/model/openai/responses');
753
+ expect(seen.at(-1).headers.authorization).toBe(`Bearer ${SECRET}`);
754
+ expect(seen.at(-1).url).toBe('/v1/responses');
755
+ });
756
+
757
+ it('keeps no path, no query and no header in its journal', async () => {
758
+ const at = await journalMark();
759
+ await throughWindow('/model/anthropic/messages?key=leak-me', { authorization: 'Bearer sk-fake-inside' });
760
+ const record = await decisionFor('127.0.0.1', at);
761
+ expect(record).toMatchObject({ listener: 'window', host: '127.0.0.1', decision: 'allow' });
762
+ expect(Object.keys(record)).toEqual(['at', 'listener', 'host', 'port', 'decision']);
763
+ expect(JSON.stringify(record)).not.toMatch(/messages|leak-me|Bearer/);
764
+ });
765
+
766
+
767
+ it('refuses a path that walks out of the grant, encoded or not', async () => {
768
+ // Same host and same credential, so not an escape; a trap once git and a private registry
769
+ // come through here, where the path is what says which repository or which package. The
770
+ // encoded forms are the point: the rest reaches the upstream verbatim, and a server that
771
+ // decodes before it resolves reads every line below as the same climb. Each one was
772
+ // measured reaching the upstream when this rule only looked for a literal `..`.
773
+ const climbs = [
774
+ '/model/anthropic/../admin',
775
+ '/model/anthropic/%25252e%25252e/admin',
776
+ '/model/anthropic/%2e%2e/admin',
777
+ '/model/anthropic/..%2fadmin',
778
+ '/model/anthropic/.%2e/admin',
779
+ '/model/anthropic/x/..%5cadmin',
780
+ '/model/anthropic/..;/admin',
781
+ '/model/anthropic/%252e%252e/admin',
782
+ '/model/anthropic/v1/../../admin',
783
+ '/model/anthropic/..',
784
+ ];
785
+ for (const path of climbs) {
786
+ const before = seen.length;
787
+ const at = await journalMark();
788
+ const answer = await throughWindow(path);
789
+ expect(answer.status, path).toBe(403);
790
+ // Refused here, not merely answered 403 after the upstream had already served it.
791
+ expect(seen.length, path).toBe(before);
792
+ // And the journal says which refusal this was. Read as `deny:no-grant` it would tell the
793
+ // user the agent guessed at a grant id, which is a different thing to have done.
794
+ expect(await decisionFor('grant-path', at), path).toMatchObject({ listener: 'window', decision: 'deny:path-walks-out' });
795
+ }
796
+ // The space learns nothing from the difference: a real id that climbs and an id that does
797
+ // not exist get the same status and the same body.
798
+ expect((await throughWindow('/model/anthropic/../admin')).body).toBe((await throughWindow('/model/nothing-here/admin')).body);
799
+ // A percent sign is not itself the offence. A scoped npm package is fetched exactly like
800
+ // this, and that window is half the reason the rule exists, so refusing every `%` is out.
801
+ expect((await throughWindow('/model/anthropic/@scope%2fname/-/name-1.0.0.tgz')).status).toBe(200);
802
+ expect(seen.at(-1).url).toBe('/v1/@scope%2fname/-/name-1.0.0.tgz');
803
+ // And the ordinary path still goes through.
804
+ expect((await throughWindow('/model/anthropic/messages')).status).toBe(200);
805
+ });
806
+
807
+ it('gives up on an upstream that accepts and never answers', async () => {
808
+ // A deadline of its own: without one, 64 of these hold every slot the window has, in and
809
+ // out, for the life of the space. This gatekeeper was started with a short one so the test
810
+ // can watch it pass; the container command carries 300000.
811
+ // A `net.Server` has no `closeAllConnections`, so its sockets are kept and destroyed by
812
+ // hand; a `close()` that waits for one is a hook that never returns.
813
+ const held = [];
814
+ const silent = net.createServer((socket) => { socket.on('error', () => {}); held.push(socket); });
815
+ await new Promise((resolve) => { silent.listen(0, '127.0.0.1', resolve); });
816
+ const port = silent.address().port;
817
+ try {
818
+ await control('POST', '/grants', { id: 'silent', upstream: `http://127.0.0.1:${port}/v1`, header: 'authorization', secret: SECRET });
819
+ const at = await journalMark();
820
+ const started = Date.now();
821
+ const answer = await throughWindow('/model/silent/messages');
822
+ expect(answer.status).toBe(504);
823
+ // The one clock reading left in this file that means anything: how long the window took,
824
+ // measured entirely inside this process and compared against nothing the program said.
825
+ expect(Date.now() - started).toBeGreaterThan(1_000);
826
+ expect((await decisionFor('127.0.0.1', at)).decision).toBe('failed:timeout');
827
+ // And it is one record, not a timeout followed by the error its own destroy causes.
828
+ const since = await recordsSince(at);
829
+ expect(since.filter((entry) => entry.decision.startsWith('failed:'))).toHaveLength(1);
830
+ } finally {
831
+ for (const socket of held) socket.destroy();
832
+ await new Promise((resolve) => {
833
+ const done = setTimeout(resolve, 2_000);
834
+ silent.close(() => { clearTimeout(done); resolve(); });
835
+ });
836
+ }
837
+ }, 30_000);
838
+
839
+ it.each([
840
+ ['its control channel on loopback', () => `http://127.0.0.1:${ports.control}/`],
841
+ ['its corridor on loopback', () => `http://127.0.0.1:${ports.corridor}/`],
842
+ ['its window on loopback', () => `http://127.0.0.1:${ports.window}/`],
843
+ // The listeners bind every interface, so the address the space reaches them on is not
844
+ // loopback. A grant pointing there would feed the window into itself until its sockets ran out.
845
+ ['its window on the address a client reaches it on', () => `http://${ownAddress()}:${ports.window}/`],
846
+ ['its corridor on that same address', () => `http://${ownAddress()}:${ports.corridor}/`],
847
+ ])('refuses to proxy into %s', async (title, upstream) => {
848
+ const id = `loop-${Math.random().toString(36).slice(2, 8)}`;
849
+ expect((await control('POST', '/grants', { id, upstream: upstream(), header: 'authorization', secret: SECRET })).status).toBe(200);
850
+ const at = await journalMark();
851
+ const answer = await throughWindow(`/model/${id}/network`);
852
+ expect(answer.status).toBe(403);
853
+ const host = new URL(upstream()).hostname;
854
+ expect((await decisionFor(host, at)).decision).toBe('deny:own-listener');
855
+ });
856
+
857
+ it('answers 502 when the upstream is not there, without the secret in the message', async () => {
858
+ const port = await freePort();
859
+ await control('POST', '/grants', { id: 'gone', upstream: `http://127.0.0.1:${port}/v1`, header: 'authorization', secret: SECRET });
860
+ const answer = await throughWindow('/model/gone/messages');
861
+ expect(answer.status).toBe(502);
862
+ expect(answer.body).not.toContain(SECRET);
863
+ });
864
+ });
865
+
866
+ describe('control channel', () => {
867
+ it.each([
868
+ ['a mode that does not exist', '/network', { mode: 'everything', domains: [] }],
869
+ ['a wildcard on the allowlist', '/network', { mode: 'allowlist', domains: ['*.googleapis.com'] }],
870
+ ['an address on the allowlist', '/network', { mode: 'allowlist', domains: ['10.0.0.1'] }],
871
+ // The list and a target go through the same predicate, so the host cannot put an entry
872
+ // on the list that the corridor would then always refuse as an address.
873
+ ['a numeric last label on the allowlist', '/network', { mode: 'allowlist', domains: ['1.1.1'] }],
874
+ ['a hex last label on the allowlist', '/network', { mode: 'allowlist', domains: ['a.com.0x2'] }],
875
+ ['a single-label name on the allowlist', '/network', { mode: 'allowlist', domains: ['localhost'] }],
876
+ ['a grant without a secret', '/grants', { id: 'x', upstream: 'https://api.example.com', header: 'authorization' }],
877
+ ['a grant with an id that is not one', '/grants', { id: '../../etc', upstream: 'https://api.example.com', header: 'authorization', secret: SECRET }],
878
+ ['a grant with a header name that is not one', '/grants', { id: 'x', upstream: 'https://api.example.com', header: 'a: b', secret: SECRET }],
879
+ ['a grant whose upstream is not a URL', '/grants', { id: 'x', upstream: 'not a url', header: 'authorization', secret: SECRET }],
880
+ ['a grant whose upstream is a file', '/grants', { id: 'x', upstream: 'file:///etc/passwd', header: 'authorization', secret: SECRET }],
881
+ ])('refuses %s', async (title, path, body) => {
882
+ const answer = await control('POST', path, body);
883
+ expect(answer.status).toBe(400);
884
+ });
885
+
886
+ it('answers 404 for anything else and 400 for a body that is not JSON', async () => {
887
+ expect((await control('GET', '/grants')).status).toBe(404);
888
+ expect((await control('POST', '/anything')).status).toBe(404);
889
+ expect((await control('POST', '/network')).status).toBe(400);
890
+ expect((await control('GET', '/health')).status).toBe(200);
891
+ });
892
+
893
+ it('records a connection that was allowed and did not happen, apart from a refusal', async () => {
894
+ // 198.51.100.0/24 is documentation space: allowed by every rule here, and nothing answers.
895
+ // The corridor's own connect timeout ends it, so this waits for the record, not the socket.
896
+ await control('POST', '/network', { mode: 'open', domains: [] });
897
+ const at = await journalMark();
898
+ connect('CONNECT one.example.test:443 HTTP/1.1', { keepOpen: true, wait: 500 });
899
+ const decision = await decisionFor('one.example.test', at);
900
+ // It never resolves here, so what is recorded is the refusal that follows the name rules.
901
+ // The failure record is covered where a host does resolve: the escape suite.
902
+ expect(decision.decision).toMatch(/^deny:unresolved:/);
903
+ }, 30_000);
904
+
905
+ it('keeps the journal readable however long the space makes its request targets', async () => {
906
+ // A plain proxy request carries its target in the request line, up to the 16 KiB header cap.
907
+ // Without a cut on the host, 201 of these made the journal larger than the host channel
908
+ // reads, and the space could hold it that way: the user's only view of what it tried.
909
+ const longName = `${'a'.repeat(200)}.${'b'.repeat(200)}.${'c'.repeat(200)}.example.com`;
910
+ // Enough of them to fill the ring with the longest host a request can carry, which is the
911
+ // worst case the host channel has to read back. These go through `http.globalAgent`, which
912
+ // keeps connections alive on Node 22, so 520 requests cost a handful of sockets.
913
+ for (let attempt = 0; attempt < 520; attempt += 1) {
914
+ await throughPlainProxy(`http://${longName}/${'p'.repeat(4_000)}`);
915
+ }
916
+ const answer = await control('GET', '/journal');
917
+ expect(answer.status).toBe(200);
918
+ // The host channel refuses a journal over a megabyte, and a full ring must stay under it.
919
+ expect(answer.body.length).toBeLessThan(1024 * 1024);
920
+ const records = JSON.parse(answer.body).records;
921
+ expect(records).toHaveLength(500);
922
+ for (const entry of records) expect(entry.host.length).toBeLessThanOrEqual(256);
923
+ expect(records.at(-1).host.length).toBe(256);
924
+ }, 60_000);
925
+
926
+ it('separates one attempt from the next by count, not by any clock', async () => {
927
+ // Every assertion in this file that reads a decision reads it after a mark, and the mark is
928
+ // a count of records for a reason: the stamp on a record is written by the program's clock
929
+ // and the mark would be read from the test's, and on Windows those disagree by about a
930
+ // timer tick. Two attempts against the same host inside one tick are indistinguishable by
931
+ // time and perfectly distinguishable by count. That is what this asserts.
932
+ const host = 'twice.example.test';
933
+ await tunnelTo(`${host}:443`);
934
+ await decisionFor(host);
935
+
936
+ const mark = await journalMark();
937
+ await tunnelTo(`${host}:443`);
938
+ await decisionFor(host, mark);
939
+
940
+ const since = await recordsSince(mark);
941
+ expect(since).toHaveLength(1);
942
+ expect(since[0].host).toBe(host);
943
+ // And a mark taken after everything sees nothing at all, however fast the machine is.
944
+ expect(await recordsSince(await journalMark())).toHaveLength(0);
945
+ });
946
+
947
+ it('keeps the number of journal records bounded and counts what it dropped', async () => {
948
+ const before = await journal();
949
+ // Through the plain-proxy path, which keeps its connections alive: a raw CONNECT needs a
950
+ // socket of its own, and 520 of those is the biggest platform-specific cost in this file
951
+ // on a Windows host, where a closed socket's port waits two minutes to come back.
952
+ for (let attempt = 0; attempt < 520; attempt += 1) {
953
+ expect((await throughPlainProxy(`http://filler-${attempt}.example.test/`)).status, 'the corridor stopped answering during the run').toBe(403);
954
+ }
955
+ // And one raw CONNECT, so the ring holds both shapes of record.
956
+ expect((await tunnelTo('blocked.test:443')).status).toMatch(/403/);
957
+ await decisionFor('blocked.test');
958
+ const after = await journal();
959
+ expect(after.records).toHaveLength(500);
960
+ expect(after.dropped).toBeGreaterThan(before.dropped);
961
+ // The oldest records went, the newest stayed.
962
+ expect(after.records.at(-1).host).toBe('blocked.test');
963
+ }, 60_000);
964
+ });
965
+ });
966
+
967
+ // The rules, called directly. The program is the container's main module and runs its listeners
968
+ // only then; a test can require it instead. This is where every range and every target form is
969
+ // covered, because the corridor takes names only and an address never reaches the range rules
970
+ // through a CONNECT any more.
971
+ describe('the rules of the gatekeeper program', () => {
972
+ const rules = createRequire(import.meta.url)(PROGRAM);
973
+
974
+ describe('isBlockedAddress', () => {
975
+ it.each([
976
+ ['this network', '0.0.0.0'],
977
+ ['this network, another address', '0.1.2.3'],
978
+ ['a private address', '10.1.2.3'],
979
+ ['carrier-grade NAT', '100.64.0.1'],
980
+ ['the end of carrier-grade NAT', '100.127.255.255'],
981
+ ['loopback', '127.0.0.1'],
982
+ ['another loopback address', '127.1.2.3'],
983
+ ['link-local and cloud metadata', '169.254.169.254'],
984
+ ['the private range around a Docker bridge', '172.17.0.1'],
985
+ ['the start of that range', '172.16.0.0'],
986
+ ['the end of that range', '172.31.255.255'],
987
+ ['IETF protocol assignments', '192.0.0.1'],
988
+ ['a home network', '192.168.1.1'],
989
+ ['benchmarking', '198.18.0.1'],
990
+ ['benchmarking, second half', '198.19.0.1'],
991
+ ['multicast', '224.0.0.1'],
992
+ ['reserved', '240.0.0.1'],
993
+ ['broadcast', '255.255.255.255'],
994
+ ['the unspecified IPv6 address', '::'],
995
+ ['IPv6 loopback', '::1'],
996
+ ['a unique local address', 'fc00::1'],
997
+ ['another unique local address', 'fd12:3456::1'],
998
+ ['IPv6 link-local', 'fe80::1'],
999
+ ['IPv6 link-local with a zone id', 'fe80::1%eth0'],
1000
+ ['IPv6 multicast', 'ff02::1'],
1001
+ ['an IPv4-mapped private address', '::ffff:10.0.0.1'],
1002
+ ['IPv4-mapped loopback', '::ffff:127.0.0.1'],
1003
+ ['IPv4-mapped loopback in hex', '::ffff:7f00:1'],
1004
+ ['an IPv4-compatible loopback address', '::127.0.0.1'],
1005
+ ['NAT64 around loopback', '64:ff9b::7f00:1'],
1006
+ ['NAT64 around a private address', '64:ff9b::a00:1'],
1007
+ ['6to4 around loopback', '2002:7f00:0001::1'],
1008
+ ['6to4 around a private address', '2002:0a00:0001::1'],
1009
+ ['something that is not an address', 'not-an-address'],
1010
+ ['an empty string', ''],
1011
+ ['a name that looks like one', '10.0.0.1.example.com'],
1012
+ ])('refuses %s (%s)', (title, address) => {
1013
+ expect(rules.isBlockedAddress(address)).toBe(true);
1014
+ });
1015
+
1016
+ it.each([
1017
+ ['a documentation address', '203.0.113.7'],
1018
+ ['another documentation range', '198.51.100.7'],
1019
+ ['a public address', '104.18.41.241'],
1020
+ ['the first public address after the private range', '172.32.0.1'],
1021
+ ['the last public address before it', '172.15.255.255'],
1022
+ ['a public address next to carrier-grade NAT', '100.63.255.255'],
1023
+ ['a public IPv6 address', '2001:db8::1'],
1024
+ ['an IPv4-mapped public address', '::ffff:203.0.113.7'],
1025
+ ['6to4 around a public address', '2002:cb00:7107::1'],
1026
+ ])('allows %s (%s)', (title, address) => {
1027
+ expect(rules.isBlockedAddress(address)).toBe(false);
1028
+ });
1029
+ });
1030
+
1031
+ describe('refuseTarget', () => {
1032
+ const inMode = (mode, domains = []) => { rules.setNetwork({ mode, domains }); };
1033
+
1034
+ it('refuses auth.openai.com in both modes, whatever the list says', () => {
1035
+ inMode('open');
1036
+ expect(rules.refuseTarget('auth.openai.com', 443)).toBe('deny:always-refused');
1037
+ expect(rules.refuseTarget('AUTH.OpenAI.com.', 443)).toBe('deny:always-refused');
1038
+ inMode('allowlist', ['auth.openai.com']);
1039
+ expect(rules.refuseTarget('auth.openai.com', 443)).toBe('deny:always-refused');
1040
+ });
1041
+
1042
+ it.each([
1043
+ // Dotted and IPv6 forms.
1044
+ '104.18.41.241', '203.0.113.7', '10.0.0.1', '127.0.0.1',
1045
+ '::ffff:104.18.41.241', '::ffff:6812:29f1', '2606:4700::6812:29f1', '64:ff9b::6812:29f1', '2002:6812:29f1::1',
1046
+ // The spellings `getaddrinfo` takes that four dotted octets do not describe.
1047
+ '1746020849', '16843009', '4294967295', '0x01010101', '0xc0a80001', '1.1.257', '1.16843009',
1048
+ '0x1.0x1.0x1.0x1', '0177.0.0.1', '0.0x0.0', '192.168.257',
1049
+ // Not a name either: no dot, non-ASCII, or a label that is not a label.
1050
+ 'localhost', 'auth.openai.com\u3002', 'auth.openai\uff0ecom', 'auth\uff61openai.com',
1051
+ '.example.com', 'exam ple.com', 'example..com', 'exa_mple.com', '-example.com', '',
1052
+ ])('refuses %j in both modes, before the port rule', (address) => {
1053
+ for (const port of [443, 80, 8443]) {
1054
+ inMode('open');
1055
+ expect(rules.refuseTarget(address, port)).toBe('deny:not-a-name');
1056
+ inMode('allowlist', ['example.com']);
1057
+ expect(rules.refuseTarget(address, port)).toBe('deny:not-a-name');
1058
+ }
1059
+ });
1060
+
1061
+ it.each([
1062
+ 'example.com', 'anything.example.org', 'sub.domain.example.co.uk', 'a.io', 'xn--80ak6aa92e.com',
1063
+ 'registry-1.docker.io', 'api.anthropic.com', '1.1.1.1.example.com', 'v2.example.com', 'EXAMPLE.com.',
1064
+ ])('takes %j for an ordinary name', (name) => {
1065
+ inMode('open');
1066
+ expect(rules.refuseTarget(name, 443)).toBe(null);
1067
+ });
1068
+
1069
+ it('allows any name in open mode, on 443 only', () => {
1070
+ inMode('open');
1071
+ expect(rules.refuseTarget('example.com', 443)).toBe(null);
1072
+ expect(rules.refuseTarget('anything.example.org', 443)).toBe(null);
1073
+ for (const port of [80, 22, 9418, 8443]) {
1074
+ expect(rules.refuseTarget('example.com', port)).toBe('deny:port');
1075
+ }
1076
+ });
1077
+
1078
+ it('allows a name on the list on 443 only in allowlist mode', () => {
1079
+ inMode('allowlist', ['example.com']);
1080
+ expect(rules.refuseTarget('example.com', 443)).toBe(null);
1081
+ expect(rules.refuseTarget('EXAMPLE.com.', 443)).toBe(null);
1082
+ for (const port of [80, 22, 8443]) {
1083
+ expect(rules.refuseTarget('example.com', port)).toBe('deny:port');
1084
+ }
1085
+ for (const name of ['sub.example.com', 'notexample.com', 'example.com.evil.test']) {
1086
+ expect(rules.refuseTarget(name, 443)).toBe('deny:not-on-allowlist');
1087
+ }
1088
+ });
1089
+ });
1090
+
1091
+ // The plumbing of an established tunnel, with plain sockets standing in for the two the
1092
+ // corridor joins. It is tested apart from the rules because no address this machine has is one
1093
+ // the corridor would tunnel to, by design, so a tunnel cannot be established here any other way.
1094
+ //
1095
+ // The sockets are built the way the program builds them: the space-facing one as an
1096
+ // `http.Server` accepts it, half-open allowed, and the one out as `net.connect` makes it, which
1097
+ // the program now also asks for half-open.
1098
+ describe('joinSockets', () => {
1099
+ const open = [];
1100
+
1101
+ afterEach(async () => {
1102
+ const items = open.splice(0);
1103
+ for (const item of items) {
1104
+ if (item.destroy) item.destroy();
1105
+ }
1106
+ for (const item of items) {
1107
+ if (!item.destroy && item.close) await new Promise((resolve) => { item.close(resolve); });
1108
+ }
1109
+ });
1110
+
1111
+ /** A listening server and the two ends of one connection to it. */
1112
+ const connectedPair = async ({ server: serverOptions = {}, dial = {} } = {}) => {
1113
+ const server = net.createServer(serverOptions);
1114
+ open.push(server);
1115
+ await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
1116
+ const accepted = new Promise((resolve) => { server.once('connection', resolve); });
1117
+ const dialled = net.connect({ host: '127.0.0.1', port: server.address().port, ...dial });
1118
+ dialled.on('error', () => {});
1119
+ await new Promise((resolve) => { dialled.once('connect', resolve); });
1120
+ const received = await accepted;
1121
+ received.on('error', () => {});
1122
+ open.push(dialled, received);
1123
+ return { dialled, received };
1124
+ };
1125
+
1126
+ /**
1127
+ * What the corridor holds after a CONNECT: `space` is the client's end, `origin` is the
1128
+ * upstream's own end, and the two sockets between them are the ones it joins.
1129
+ * `originKeepsReading` gives the stand-in upstream half-open, which a server that answers
1130
+ * and then keeps reading has to ask for itself.
1131
+ */
1132
+ const tunnel = async ({ originKeepsReading = false } = {}) => {
1133
+ // The stand-in space keeps half-open too, because a client that goes on writing after it
1134
+ // has read a FIN has to ask for that itself. That is the client's own choice in
1135
+ // production, and what is under test here is only what the corridor does between them.
1136
+ const fromSpace = await connectedPair({ server: { allowHalfOpen: true }, dial: { allowHalfOpen: true } });
1137
+ const toUpstream = await connectedPair({ server: { allowHalfOpen: originKeepsReading }, dial: { allowHalfOpen: true } });
1138
+ let released = 0;
1139
+ rules.joinSockets(fromSpace.received, toUpstream.dialled, () => { released += 1; });
1140
+ return { space: fromSpace.dialled, origin: toUpstream.received, releases: () => released };
1141
+ };
1142
+
1143
+ // The defect this was written for: the corridor destroyed both sockets when the upstream
1144
+ // finished, and a destroy with unread bytes in the receive queue becomes a reset, which
1145
+ // makes the kernel throw away the send queue with it. One direction alone does not show it,
1146
+ // because the pipe's own backpressure keeps the buffer small. Both directions at once do.
1147
+ it('carries a whole answer while the space is still uploading', async () => {
1148
+ const { space, origin } = await tunnel();
1149
+ const payload = crypto.randomBytes(4 * 1024 * 1024);
1150
+ const expected = crypto.createHash('sha256').update(payload).digest('hex');
1151
+
1152
+ // The origin reads the upload, or backpressure stops it answering at all.
1153
+ origin.resume();
1154
+ // The space keeps writing while it reads, so the corridor's socket to it always has
1155
+ // something unread.
1156
+ const uploading = setInterval(() => {
1157
+ if (!space.destroyed && space.writable) space.write(crypto.randomBytes(16 * 1024));
1158
+ }, 5);
1159
+ // And it reads slowly, so the corridor is still holding bytes when the origin finishes.
1160
+ space.pause();
1161
+ const chunks = [];
1162
+ let received = 0;
1163
+ const reading = setInterval(() => {
1164
+ const chunk = space.read(16 * 1024);
1165
+ if (chunk) {
1166
+ chunks.push(chunk);
1167
+ received += chunk.length;
1168
+ }
1169
+ }, 15);
1170
+
1171
+ origin.end(payload);
1172
+ await new Promise((resolve) => {
1173
+ const check = setInterval(() => {
1174
+ if (received >= payload.length || space.destroyed) {
1175
+ clearInterval(check);
1176
+ resolve();
1177
+ }
1178
+ }, 20);
1179
+ setTimeout(() => { clearInterval(check); resolve(); }, 60_000);
1180
+ });
1181
+ clearInterval(uploading);
1182
+ clearInterval(reading);
1183
+ // Whatever is left in the buffer after the last tick.
1184
+ for (let chunk = space.read(); chunk; chunk = space.read()) chunks.push(chunk);
1185
+
1186
+ const bytes = Buffer.concat(chunks);
1187
+ expect(bytes).toHaveLength(payload.length);
1188
+ expect(crypto.createHash('sha256').update(bytes).digest('hex')).toBe(expected);
1189
+ }, 90_000);
1190
+
1191
+ it('passes a half-close on as a half-close, from the client side', async () => {
1192
+ // The origin keeps reading after the FIN, which is what a server that still owes an
1193
+ // answer does; a default socket ends its own writable side the moment it reads one.
1194
+ const { space, origin } = await tunnel({ originKeepsReading: true });
1195
+ origin.resume();
1196
+ const heard = new Promise((resolve) => { origin.once('end', resolve); });
1197
+ space.end('a request that ends with a FIN');
1198
+ await heard;
1199
+
1200
+ // The upstream heard the close and is still there. Destroying it here would take the
1201
+ // answer with it, which is what the client's `end` used to do.
1202
+ expect(origin.destroyed).toBe(false);
1203
+
1204
+ const answer = 'the answer, after the client half closed';
1205
+ const arrived = new Promise((resolve) => {
1206
+ const chunks = [];
1207
+ space.on('data', (chunk) => {
1208
+ chunks.push(chunk);
1209
+ if (Buffer.concat(chunks).toString().includes(answer)) resolve(Buffer.concat(chunks).toString());
1210
+ });
1211
+ setTimeout(() => resolve(Buffer.concat(chunks).toString()), 3_000);
1212
+ });
1213
+ origin.write(answer);
1214
+ expect(await arrived).toContain(answer);
1215
+ }, 30_000);
1216
+
1217
+ // The other direction, which nothing covered until it was measured against the real program:
1218
+ // the upstream answers, half-closes, and goes on reading what the space is still sending.
1219
+ it('passes a half-close on from the upstream side, and still carries what the space sends', async () => {
1220
+ const { space, origin } = await tunnel({ originKeepsReading: true });
1221
+ space.resume();
1222
+ const heard = new Promise((resolve) => { space.once('end', resolve); });
1223
+ origin.end('the answer, and then a FIN');
1224
+ await heard;
1225
+
1226
+ const later = 'what the space sent afterwards';
1227
+ const arrived = new Promise((resolve) => {
1228
+ const chunks = [];
1229
+ origin.on('data', (chunk) => {
1230
+ chunks.push(chunk);
1231
+ if (Buffer.concat(chunks).toString().includes(later)) resolve(Buffer.concat(chunks).toString());
1232
+ });
1233
+ setTimeout(() => resolve(Buffer.concat(chunks).toString()), 3_000);
1234
+ });
1235
+ space.write(later);
1236
+ expect(await arrived).toContain(later);
1237
+ }, 30_000);
1238
+
1239
+ it('gives the place back when one side dies under load, without waiting for the idle timeout', async () => {
1240
+ const { space, origin, releases } = await tunnel();
1241
+ origin.resume();
1242
+ // The origin pushes and the space reads nothing, so the corridor's socket out is paused by
1243
+ // backpressure. A paused socket never notices its peer leaving: `pipe` carries a close but
1244
+ // not a death, so without telling the peer to go the second close never came and the place
1245
+ // stayed taken for the five minutes of the idle timeout. Rounds of that reach the cap.
1246
+ space.pause();
1247
+ const pushing = setInterval(() => {
1248
+ if (!origin.destroyed && origin.writable) origin.write(crypto.randomBytes(256 * 1024));
1249
+ }, 5);
1250
+ await new Promise((resolve) => { setTimeout(resolve, 400); });
1251
+ space.destroy();
1252
+ await new Promise((resolve) => { setTimeout(resolve, 800); });
1253
+ clearInterval(pushing);
1254
+
1255
+ expect(releases()).toBe(1);
1256
+ expect(origin.destroyed).toBe(true);
1257
+ }, 30_000);
1258
+
1259
+ it('keeps the place while a half-closed tunnel is still carrying', async () => {
1260
+ // An origin that goes on after the FIN. With one that closes instead, the tunnel is over
1261
+ // and the place comes back, which is the test above.
1262
+ const { space, origin, releases } = await tunnel({ originKeepsReading: true });
1263
+ origin.resume();
1264
+ space.end('a request that ends with a FIN');
1265
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
1266
+ // Half-closed is not gone: the answer may still be on its way.
1267
+ expect(releases()).toBe(0);
1268
+ expect(origin.destroyed).toBe(false);
1269
+ }, 30_000);
1270
+ });
1271
+
1272
+ describe('chooseAddress', () => {
1273
+ const v4 = { address: '203.0.113.7', family: 4 };
1274
+ const other4 = { address: '198.51.100.7', family: 4 };
1275
+ const v6 = { address: '2001:db8::1', family: 6 };
1276
+
1277
+ it('takes IPv4 whatever order the resolver answered in', () => {
1278
+ // The gatekeeper's outer network is made with --ipv6=false, so an IPv6 address there is a
1279
+ // connection that cannot happen. A resolver that answers AAAA first would otherwise turn an
1280
+ // allowed host into a 502.
1281
+ expect(rules.chooseAddress([v4, v6])).toBe(v4);
1282
+ expect(rules.chooseAddress([v6, v4])).toBe(v4);
1283
+ expect(rules.chooseAddress([v6, v6, v4, other4])).toBe(v4);
1284
+ });
1285
+
1286
+ it('keeps the resolver order within a family', () => {
1287
+ expect(rules.chooseAddress([v4, other4])).toBe(v4);
1288
+ expect(rules.chooseAddress([other4, v4])).toBe(other4);
1289
+ });
1290
+
1291
+ it('still attempts an IPv6-only host, so its failure says what happened', () => {
1292
+ expect(rules.chooseAddress([v6])).toBe(v6);
1293
+ });
1294
+ });
1295
+
1296
+ describe('walksOutOfGrant', () => {
1297
+ it.each([
1298
+ ['a literal climb', '/../admin'],
1299
+ ['both dots encoded', '/%2e%2e/admin'],
1300
+ ['the separator encoded', '/..%2fadmin'],
1301
+ ['one dot encoded', '/.%2e/admin'],
1302
+ ['a backslash separator, encoded', '/x/..%5cadmin'],
1303
+ ['a path parameter after the dots', '/..;/admin'],
1304
+ ['encoded twice', '/%252e%252e/admin'],
1305
+ ['encoded four times', '/%25252e%25252e/admin'],
1306
+ ['encoded five times', '/%2525252e%2525252e/admin'],
1307
+ ['dots and separator both encoded', '/%2e%2e%2fadmin'],
1308
+ ['a climb at the very end', '/v1/..'],
1309
+ ['a climb before the query', '/v1/..?x=1'],
1310
+ ['a percent that is not an escape', '/%zz'],
1311
+ ])('refuses %s', (_what, rest) => {
1312
+ expect(rules.walksOutOfGrant(rest)).toBe(true);
1313
+ });
1314
+
1315
+ it.each([
1316
+ ['an ordinary path', '/messages'],
1317
+ ['a scoped npm package', '/@scope%2fname/-/name-1.0.0.tgz'],
1318
+ ['an encoded space in a query', '/messages?q=hello%20world'],
1319
+ ['two dots inside a name', '/a..b/c'],
1320
+ ['three dots', '/...'],
1321
+ ['a git ref request', '/owner/repo.git/info/refs?service=git-upload-pack'],
1322
+ ['nothing at all', ''],
1323
+ ])('allows %s', (_what, rest) => {
1324
+ expect(rules.walksOutOfGrant(rest)).toBe(false);
1325
+ });
1326
+ });
1327
+
1328
+ describe('splitTarget', () => {
1329
+ it('reads a name, a port and a bracketed IPv6 address', () => {
1330
+ expect(rules.splitTarget('example.com:443')).toEqual({ host: 'example.com', port: 443 });
1331
+ expect(rules.splitTarget('[2001:db8::1]:8443')).toEqual({ host: '2001:db8::1', port: 8443 });
1332
+ });
1333
+
1334
+ it.each(['', 'example.com', 'example.com:', ':443', 'example.com:0', 'example.com:99999', 'example.com:-1', 'a b:443', 'http://example.com/x:443'])(
1335
+ 'refuses %j',
1336
+ (target) => {
1337
+ expect(rules.splitTarget(target)).toBe(null);
1338
+ },
1339
+ );
1340
+ });
1341
+ });
1342
+
1343
+ describe('the gatekeeper program as a file', () => {
1344
+ const source = readFileSync(PROGRAM, 'utf8');
1345
+
1346
+ it('binds the control channel to loopback and never to the space-facing interface', () => {
1347
+ expect(source).toContain("const CONTROL_HOST = '127.0.0.1';");
1348
+ expect(source).toContain('await listen(control, controlPort, CONTROL_HOST);');
1349
+ });
1350
+
1351
+ it('writes no refusal into a tunnel that is already carrying bytes', () => {
1352
+ // An upstream reset halfway through a download would otherwise put `HTTP/1.1 502` in the
1353
+ // middle of somebody's TLS stream. It cannot be reached from here, because a tunnel needs an
1354
+ // address this machine may not tunnel to, so what is checked is that the guard is there and
1355
+ // that it is armed where the tunnel becomes a tunnel.
1356
+ expect(source).toContain('if (!carrying && !clientSocket.destroyed');
1357
+ expect(source).toContain('carrying = true;');
1358
+ expect(source.indexOf('carrying = true;')).toBeGreaterThan(source.indexOf("upstream.on('connect'"));
1359
+ });
1360
+
1361
+ it('lets a 502 leave the same way a 403 does, drained rather than reset', () => {
1362
+ // The answer to an allowed host that could not be reached carries the one header that tells
1363
+ // the space "allowed, and the connection out failed" apart from "refused", which is the
1364
+ // distinction the journal and this reason header exist for. Destroying the socket in the same
1365
+ // tick would lose it exactly as it lost the 403 before this round: a close with unread bytes
1366
+ // in the receive queue is a reset, and the reset takes the queued answer with it.
1367
+ //
1368
+ // It cannot be driven from here, for the same reason as the guard above: a 502 needs a name
1369
+ // that resolves to an address this machine is allowed to tunnel to, and the unit suite
1370
+ // resolves nothing. The drain itself is exercised for real by the 403 test, which fills the
1371
+ // receive queue against this same `refuse`. What is checked here is that this branch hands
1372
+ // the socket to `refuse` and does not pull it out from under it.
1373
+ const branch = source.slice(source.indexOf("upstream.on('error'"), source.indexOf('upstream.setTimeout(CONNECT_TIMEOUT_MS'));
1374
+ expect(branch).toContain("refuse(clientSocket, 502, 'Bad Gateway', UNREACHABLE);");
1375
+ expect(branch).toContain('releasePlace();');
1376
+ expect(branch).toContain('upstream.destroy();');
1377
+ // `abort()` destroys the client socket, and it stays for the timeout, the carrying case and a
1378
+ // client that left. It must not be what ends a socket that has just been given an answer.
1379
+ expect(branch.slice(0, branch.indexOf('abort();'))).toContain('return;');
1380
+ });
1381
+
1382
+ it('connects to the address it checked, and never resolves the name again', () => {
1383
+ expect(source).toContain('net.connect({ host: resolved.address, port, allowHalfOpen: true })');
1384
+ expect(source).toContain('lookup: fixedLookup(chosen.address, chosen.family)');
1385
+ });
1386
+
1387
+ it('needs nothing but the standard library', () => {
1388
+ const required = [...source.matchAll(/require\('([^']+)'\)/g)].map((match) => match[1]);
1389
+ expect(required.every((name) => name.startsWith('node:'))).toBe(true);
1390
+ });
1391
+ });