@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,914 @@
1
+ 'use strict';
2
+
3
+ // The program of the gatekeeper container: the space's only way out.
4
+ //
5
+ // It runs on the Node of the base image, with nothing but the standard library, and it is
6
+ // delivered over `exec` on stdin into the container's tmpfs. Nothing about it is written to
7
+ // disk on the host side of a space, and it holds every secret in memory only.
8
+ //
9
+ // Three listeners:
10
+ // corridor an HTTP CONNECT proxy for the space, on the space-facing interface
11
+ // window a reverse proxy that adds a secret the space never sees, on the same interface
12
+ // control the host's channel, on the gatekeeper's own loopback, which the space cannot reach
13
+ //
14
+ // Arguments: <space-facing bind host> <corridor port> <window port> <control port>
15
+ // [<window deadline in ms>]. They are arguments so that the container command and the tests run
16
+ // the very same program; the last one lets a test watch a deadline pass without waiting five
17
+ // minutes for it. None of them changes a decision this program makes.
18
+ //
19
+ // Everything the space sends is hostile input. Nothing it can send may end the process:
20
+ // a crash of the gatekeeper is a denial of service the agent could trigger at will.
21
+
22
+ const dns = require('node:dns');
23
+ const http = require('node:http');
24
+ const https = require('node:https');
25
+ const net = require('node:net');
26
+ const os = require('node:os');
27
+
28
+ const [bindHost, corridorPort, windowPort, controlPort, windowDeadline, corridorCap, windowCap, controlCap] = process.argv.slice(2);
29
+
30
+ /**
31
+ * A cap given on the command line, or the production number when there is none.
32
+ *
33
+ * The caps are arguments for the same reason the window's deadline is: they are lengths, not
34
+ * decisions, and a test that has to exceed one needs it small. A drop only happens when more
35
+ * connections are *accepted* than the cap allows, and a busy machine accepts slowly while the
36
+ * kernel's backlog keeps taking handshakes — so a flood of 300 clients against a cap of 128 can
37
+ * leave every one of them connected and nothing accepted past the cap, and the flood goes
38
+ * unrecorded. At a cap of 4 that cannot happen. The container command carries the production
39
+ * numbers and the hardening test asserts the command, so nothing here can raise them.
40
+ */
41
+ const cap = (given, production) => (Number.isInteger(Number(given)) && Number(given) > 0 ? Number(given) : production);
42
+ const CONTROL_HOST = '127.0.0.1';
43
+
44
+ // 443 and nothing else, in both modes, for two reasons.
45
+ //
46
+ // A space that could reach any port could attack a third party from the user's address: a port
47
+ // scan, an SSH brute-force, mail abuse, with the user as the apparent origin. Data can leave over
48
+ // 443 whatever anyone does here, but attacking a service that does not listen on 443 cannot, so
49
+ // this is a real restriction and not a speed bump.
50
+ //
51
+ // And it costs almost nothing. Services on other ports are overwhelmingly private ones, internal
52
+ // registries and internal git, and the private-address block already refuses those in both modes
53
+ // at every port. Public HTTPS is essentially all 443. When a port is genuinely needed, it belongs
54
+ // in the grant dialog as an explicit `address:port` the user opens, never in a mode-wide rule.
55
+ const ALLOWED_PORT = 443;
56
+ // Refused in both modes, whatever the allowlist says. In allowlist mode that makes it
57
+ // unreachable. In open mode it only makes it harder: the space reaches every public host on 443,
58
+ // so it can go through a third-party intermediary and the corridor sees only that name. What
59
+ // holds in both modes is that the long-lived refresh token is never inside the space at all.
60
+ const ALWAYS_REFUSED = ['auth.openai.com'];
61
+
62
+ // The space can open connections without end, so all of these are caps it cannot lift.
63
+ const MAX_TUNNELS = 64;
64
+ // Sockets that are not tunnels yet: a client has ten seconds to send its request line, and
65
+ // MAX_TUNNELS counts only what is established. Twice the tunnel cap leaves room for every
66
+ // handshake a working space makes and still refuses a flood, which the space can otherwise use
67
+ // to have this process killed and the journal of what it tried killed with it.
68
+ const MAX_CORRIDOR_CONNECTIONS = cap(corridorCap, 2 * MAX_TUNNELS);
69
+ // The window serves one request per connection at a time, and each one holds a socket out.
70
+ // The same number in and out, so the outgoing side can never grow past the incoming one.
71
+ const MAX_WINDOW_CONNECTIONS = cap(windowCap, 64);
72
+ // No byte from the upstream for this long and the request is over. The same number as a tunnel's
73
+ // idle limit and for the same reason: a model answer streams, so what matters is silence, not
74
+ // how long the whole answer takes. Without it an upstream that accepts and never answers holds
75
+ // its socket in and its socket out for the life of the space, and 64 of those are every slot
76
+ // the window has. The space cannot choose a grant's upstream, so this is about one provider
77
+ // going quiet rather than about the agent.
78
+ const WINDOW_IDLE_MS = Number(windowDeadline) > 0 ? Number(windowDeadline) : 300_000;
79
+ // Only the host talks to the control channel, one request at a time.
80
+ const MAX_CONTROL_CONNECTIONS = cap(controlCap, 8);
81
+ // Traffic that never reached a decision is journalled at most this often, per listener and per
82
+ // kind. The flood is the point of the record, and 500 records of it would erase everything else.
83
+ const NOTE_INTERVAL_MS = 60_000;
84
+ // No byte in either direction for this long and the tunnel goes. A model answer streams,
85
+ // and a long turn can be quiet for minutes, so this is generous on purpose.
86
+ const TUNNEL_IDLE_MS = 300_000;
87
+ const CONNECT_TIMEOUT_MS = 15_000;
88
+
89
+ const MAX_HEADER_BYTES = 16 * 1024;
90
+ const HEADERS_TIMEOUT_MS = 10_000;
91
+ const REQUEST_TIMEOUT_MS = 60_000;
92
+ const CONTROL_BODY_BYTES = 1024 * 1024;
93
+
94
+ // The journal is small on purpose: it is a record of attempts, not a log.
95
+ const JOURNAL_RECORDS = 500;
96
+ // The same field caps the host channel reads back with, so a full ring always fits its answer.
97
+ const MAX_JOURNAL_HOST = 256;
98
+ const MAX_JOURNAL_DECISION = 64;
99
+ const MAX_ALLOWED_DOMAINS = 500;
100
+
101
+ const DOMAIN_PATTERN = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
102
+ // A last label that is a number or hex is an address in one of the spellings `getaddrinfo` takes.
103
+ const NUMERIC_LABEL = /^(\d+|0x[0-9a-f]+)$/;
104
+ const GRANT_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
105
+ const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
106
+ const MAX_SECRET_LENGTH = 8192;
107
+
108
+ // Everything the host sets, in memory only. Never a file, never an environment variable,
109
+ // never an argument, never a label. A restart starts from "nothing is allowed".
110
+ let networkMode = 'allowlist';
111
+ let allowedDomains = new Set();
112
+ const grants = new Map();
113
+
114
+ const journal = [];
115
+ let journalDropped = 0;
116
+
117
+ /**
118
+ * One record per attempt: when, which listener, where to, and what was decided. Never a path,
119
+ * never a query, never a body, never a header value, never a grant's secret. When the ring is
120
+ * full the oldest record goes and `dropped` counts it, so the host can say that it is not
121
+ * looking at the whole story.
122
+ *
123
+ * The host is cut to the length the host channel reads back. A plain proxy request may carry a
124
+ * 16 KiB request line, and without this cut a space could make the whole journal too large to
125
+ * read and hold it that way, which would take the user's only view of what it tried.
126
+ */
127
+ function record(listener, host, port, decision) {
128
+ journal.push({
129
+ at: new Date().toISOString(),
130
+ listener,
131
+ host: String(host || '').slice(0, MAX_JOURNAL_HOST),
132
+ port: Number(port) || 0,
133
+ decision: String(decision).slice(0, MAX_JOURNAL_DECISION),
134
+ });
135
+ if (journal.length > JOURNAL_RECORDS) {
136
+ journal.shift();
137
+ journalDropped += 1;
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------------------------
142
+ // Addresses
143
+ //
144
+ // Refusal is by resolved address, in both network modes. The list covers the ranges that lead
145
+ // back to the user's own machine, to their network, or to a cloud metadata service.
146
+ // ---------------------------------------------------------------------------------------------
147
+
148
+ function parseIPv4(text) {
149
+ const parts = String(text).split('.');
150
+ if (parts.length !== 4) return null;
151
+ const bytes = [];
152
+ for (const part of parts) {
153
+ if (!/^\d{1,3}$/.test(part)) return null;
154
+ const value = Number(part);
155
+ if (value > 255) return null;
156
+ bytes.push(value);
157
+ }
158
+ return bytes;
159
+ }
160
+
161
+ /** 16 bytes, or null when this is not an IPv6 address. A zone id is cut off first. */
162
+ function parseIPv6(text) {
163
+ const address = String(text).split('%')[0];
164
+ if (!address.includes(':')) return null;
165
+ const [head, tail, ...rest] = address.split('::');
166
+ if (rest.length > 0) return null;
167
+ const readGroups = (part) => {
168
+ if (part === undefined || part === '') return [];
169
+ const groups = [];
170
+ const pieces = part.split(':');
171
+ for (let index = 0; index < pieces.length; index += 1) {
172
+ const piece = pieces[index];
173
+ // The last group may be a dotted IPv4 address: ::ffff:127.0.0.1 and friends.
174
+ if (index === pieces.length - 1 && piece.includes('.')) {
175
+ const embedded = parseIPv4(piece);
176
+ if (!embedded) return null;
177
+ groups.push(embedded[0] * 256 + embedded[1], embedded[2] * 256 + embedded[3]);
178
+ continue;
179
+ }
180
+ if (!/^[0-9a-fA-F]{1,4}$/.test(piece)) return null;
181
+ groups.push(Number.parseInt(piece, 16));
182
+ }
183
+ return groups;
184
+ };
185
+ const left = readGroups(head);
186
+ const right = tail === undefined ? [] : readGroups(tail);
187
+ if (left === null || right === null) return null;
188
+ const missing = 8 - left.length - right.length;
189
+ if (tail === undefined ? missing !== 0 : missing < 0) return null;
190
+ const groups = [...left, ...new Array(tail === undefined ? 0 : missing).fill(0), ...right];
191
+ if (groups.length !== 8) return null;
192
+ const bytes = [];
193
+ for (const group of groups) bytes.push((group >> 8) & 0xff, group & 0xff);
194
+ return bytes;
195
+ }
196
+
197
+ function blockedIPv4(bytes) {
198
+ const [a, b, c] = bytes;
199
+ return a === 0 // 0.0.0.0/8, "this network"
200
+ || a === 10 // 10.0.0.0/8
201
+ || (a === 100 && b >= 64 && b <= 127) // 100.64.0.0/10, carrier-grade NAT
202
+ || a === 127 // 127.0.0.0/8, loopback
203
+ || (a === 169 && b === 254) // 169.254.0.0/16, link-local and the cloud metadata address
204
+ || (a === 172 && b >= 16 && b <= 31) // 172.16.0.0/12
205
+ || (a === 192 && b === 0 && c === 0) // 192.0.0.0/24, IETF protocol assignments
206
+ || (a === 192 && b === 168) // 192.168.0.0/16
207
+ || (a === 198 && (b === 18 || b === 19)) // 198.18.0.0/15, benchmarking
208
+ || a >= 224; // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved, broadcast included
209
+ }
210
+
211
+ /** The IPv4 address inside an IPv6 one, for the three forms that carry one, or null. */
212
+ function embeddedIPv4(bytes) {
213
+ const zeros = (from, to) => bytes.slice(from, to).every((byte) => byte === 0);
214
+ // ::ffff:0:0/96, the IPv4-mapped form, and the deprecated IPv4-compatible ::a.b.c.d.
215
+ if (zeros(0, 10) && ((bytes[10] === 0xff && bytes[11] === 0xff) || (bytes[10] === 0 && bytes[11] === 0 && !zeros(12, 16)))) {
216
+ return bytes.slice(12, 16);
217
+ }
218
+ // 64:ff9b::/96, NAT64.
219
+ if (bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b && zeros(4, 12)) {
220
+ return bytes.slice(12, 16);
221
+ }
222
+ // 2002::/16, 6to4: the IPv4 address sits in the next four bytes.
223
+ if (bytes[0] === 0x20 && bytes[1] === 0x02) {
224
+ return bytes.slice(2, 6);
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function blockedIPv6(bytes) {
230
+ const embedded = embeddedIPv4(bytes);
231
+ if (embedded) return blockedIPv4(embedded);
232
+ if (bytes.every((byte) => byte === 0)) return true; // ::
233
+ if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true; // ::1
234
+ if ((bytes[0] & 0xfe) === 0xfc) return true; // fc00::/7, unique local
235
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return true; // fe80::/10, link-local
236
+ if (bytes[0] === 0xff) return true; // ff00::/8, multicast
237
+ return false;
238
+ }
239
+
240
+ /** True for an address that leads back to the user's machine, their network, or a metadata service. */
241
+ function isBlockedAddress(text) {
242
+ const v4 = parseIPv4(text);
243
+ if (v4) return blockedIPv4(v4);
244
+ const v6 = parseIPv6(text);
245
+ if (v6) return blockedIPv6(v6);
246
+ // Not an address we can read is not an address we let through.
247
+ return true;
248
+ }
249
+
250
+ const lookupAll = (host) => new Promise((resolve) => {
251
+ dns.lookup(host, { all: true, verbatim: true }, (error, answers) => {
252
+ if (error || !Array.isArray(answers) || answers.length === 0) {
253
+ resolve({ addresses: [], reason: error ? String(error.code || error.message) : 'no address' });
254
+ return;
255
+ }
256
+ resolve({ addresses: answers, reason: '' });
257
+ });
258
+ });
259
+
260
+ /**
261
+ * Which of the resolved answers to connect to. IPv4 first, whatever order the resolver gave them,
262
+ * because the gatekeeper's outer network is created with `--ipv6=false` and an IPv6 address there
263
+ * is a connection that cannot be made. A resolver that answers AAAA first would otherwise turn a
264
+ * perfectly allowed host into a 502 that names nothing. An IPv6-only host is still attempted, and
265
+ * fails with a message that says what happened.
266
+ *
267
+ * This changes which checked address is used and never whether it was checked: every answer goes
268
+ * through the block list before any of them is chosen.
269
+ */
270
+ const chooseAddress = (addresses) => addresses.find((answer) => answer.family === 4) ?? addresses[0];
271
+
272
+ /**
273
+ * Resolves the name itself and refuses when any answer is a blocked address, then hands back the
274
+ * one address the caller must connect to. The caller never resolves the name again: a second
275
+ * lookup is where DNS rebinding gets in.
276
+ */
277
+ async function resolveAllowedAddress(host) {
278
+ const { addresses, reason } = await lookupAll(host);
279
+ if (addresses.length === 0) return { error: `unresolved:${reason}` };
280
+ if (addresses.some((answer) => isBlockedAddress(answer.address))) return { error: 'blocked-address' };
281
+ const chosen = chooseAddress(addresses);
282
+ return { address: chosen.address, family: chosen.family };
283
+ }
284
+
285
+ /** A lookup that answers with the one address that was already checked, in either shape Node asks for. */
286
+ const fixedLookup = (address, family) => (hostname, options, callback) => {
287
+ if (options && options.all) callback(null, [{ address, family }]);
288
+ else callback(null, address, family);
289
+ };
290
+
291
+ // ---------------------------------------------------------------------------------------------
292
+ // Corridor
293
+ // ---------------------------------------------------------------------------------------------
294
+
295
+ /** `host:port` or `[v6]:port` from a CONNECT line. Null for anything else. */
296
+ function splitTarget(text) {
297
+ const value = String(text || '');
298
+ const separator = value.lastIndexOf(':');
299
+ if (separator <= 0) return null;
300
+ const port = Number(value.slice(separator + 1));
301
+ let host = value.slice(0, separator);
302
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
303
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || host.length === 0 || host.length > 255) return null;
304
+ if (/[\s/@]/.test(host)) return null;
305
+ return { host, port };
306
+ }
307
+
308
+ const normalizeName = (host) => String(host).trim().toLowerCase().replace(/\.$/, '');
309
+
310
+ /**
311
+ * Why this target may not pass, or null when it may. Names match exactly: no wildcards, no suffixes.
312
+ *
313
+ * The corridor takes names and never addresses, in both modes. A literal address walks past
314
+ * every rule that is about a name: `auth.openai.com` is refused and its address was not, and a
315
+ * space can learn that address from a public DNS-over-HTTPS resolver through this same corridor.
316
+ * Comparing resolved addresses instead would be unreliable in both directions, because a CDN's
317
+ * answers rotate. The space has no public resolver of its own, so every CONNECT it can make on
318
+ * purpose is already by name and nothing it needs is lost.
319
+ *
320
+ * Reading four dotted octets is not enough to know an address when you see one. `getaddrinfo`
321
+ * on this image's C library also takes `1746020849`, `0x01010101`, `1.1.257` and
322
+ * `0x1.0x1.0x1.0x1`, and the block list only catches private ranges, so a public address in one
323
+ * of those spellings went straight out. So the target must look like a domain name instead: the
324
+ * same name rule the allowlist uses, and a last label that is not a number and not hex, because
325
+ * no top-level domain is either. That closes every one of those spellings at once, and it keeps
326
+ * non-ASCII out as well: `auth.openai.com。` with an ideographic full stop resolves on this C
327
+ * library exactly like the name it imitates, and today only the HTTP parser stands in the way.
328
+ */
329
+ function refuseTarget(host, port) {
330
+ const name = normalizeName(host);
331
+ if (ALWAYS_REFUSED.includes(name)) return 'deny:always-refused';
332
+ if (parseIPv4(name) || parseIPv6(name) || !isDomainName(name)) return 'deny:not-a-name';
333
+ if (port !== ALLOWED_PORT) return 'deny:port';
334
+ if (networkMode === 'open') return null;
335
+ return allowedDomains.has(name) ? null : 'deny:not-on-allowlist';
336
+ }
337
+
338
+ /** A name with an ordinary last label: not a number, not hex, and the label rules of a domain. */
339
+ const isDomainName = (name) => DOMAIN_PATTERN.test(name) && !NUMERIC_LABEL.test(name.slice(name.lastIndexOf('.') + 1));
340
+
341
+ let openTunnels = 0;
342
+
343
+ // The two things the space is told apart, and the only two. `refused` covers every reason the
344
+ // gatekeeper said no; `unreachable` means it said yes and the connection out did not happen.
345
+ //
346
+ // The reason why it said no stays in the journal, where the user reads it. Telling the space
347
+ // would hand it a resolver oracle: walking a list of names and sorting `deny:blocked-address`
348
+ // from `deny:unresolved` would map the user's internal DNS without reaching any of it, and
349
+ // telling `deny:not-on-allowlist` from `deny:not-a-name` says which mode the space is in.
350
+ // How many refused clients may be waited on at once, against the corridor's cap of 128. Small
351
+ // on purpose: waiting is a courtesy to a client that is still talking, not a place it can hold.
352
+ const MAX_LINGERING_REFUSALS = 16;
353
+ const lingeringRefusals = new Set();
354
+
355
+ const REFUSED = 'refused';
356
+ const UNREACHABLE = 'unreachable';
357
+
358
+ /**
359
+ * The corridor's answer to a client it will not serve. The reason header is what tells a reader
360
+ * "the gatekeeper refused this" from "the gatekeeper allowed it and the connection out failed",
361
+ * which is a difference nobody could see in a bare 502, and nothing more than that.
362
+ */
363
+ function refuse(socket, status, text, reason) {
364
+ try {
365
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nx-gatekeeper-reason: ${reason}\r\nConnection: close\r\n\r\n`);
366
+ // Drain whatever the client is still sending, and wait for it to finish before closing.
367
+ // Closing a socket that still has unread bytes in its receive queue sends a reset, and a
368
+ // reset throws away what the client has received and not yet read: measured, a client that
369
+ // wrote a megabyte after its CONNECT lost its 403 to it in three runs out of five. The
370
+ // refusal stands either way — nothing gets through — but the reason header is the only
371
+ // thing that tells the space why, so it should arrive.
372
+ socket.resume();
373
+ // And the socket still has to be let go, or this is the wedge again: an `http.Server` socket
374
+ // allows half-open, the request timeouts stop applying once a CONNECT has been handed over,
375
+ // and the tunnel's idle timeout is only armed on a tunnel that exists, so a client that never
376
+ // closes keeps its socket for as long as it likes. Measured before this: 140 of those filled
377
+ // the connection cap and the corridor answered nothing, with nothing at all open. So a client
378
+ // that finishes gets its socket closed properly, and only the last few are ever waited on:
379
+ // past that the oldest goes, which costs it a readable reason and costs the corridor nothing.
380
+ const release = () => { lingeringRefusals.delete(socket); if (!socket.destroyed) socket.destroySoon(); };
381
+ lingeringRefusals.add(socket);
382
+ socket.on('close', () => lingeringRefusals.delete(socket));
383
+ socket.on('error', release);
384
+ socket.on('end', release);
385
+ while (lingeringRefusals.size > MAX_LINGERING_REFUSALS) {
386
+ const oldest = lingeringRefusals.values().next().value;
387
+ lingeringRefusals.delete(oldest);
388
+ oldest.destroy();
389
+ }
390
+ } catch {
391
+ socket.destroy();
392
+ }
393
+ }
394
+
395
+ async function openTunnel(request, clientSocket, head) {
396
+ // Without this an error on a socket we are not reading yet ends the process. Stage 0 lost a
397
+ // corridor to a client that reset the connection after a refusal.
398
+ clientSocket.on('error', () => {});
399
+ // A client that leaves while its name is being resolved, or while the connection out is still
400
+ // being made, must free its place at once. `end` matters as much as `close`: a socket the
401
+ // corridor has not started reading from stays half-open after the client's FIN, and without
402
+ // this the space could hold every place in the corridor by opening and leaving.
403
+ let release = () => {};
404
+ const clientLeft = () => release();
405
+ clientSocket.on('close', clientLeft);
406
+ clientSocket.on('end', clientLeft);
407
+
408
+ const target = splitTarget(request.url);
409
+ if (!target) {
410
+ record('corridor', '', 0, 'deny:malformed');
411
+ refuse(clientSocket, 400, 'Bad Request', REFUSED);
412
+ return;
413
+ }
414
+ const { host, port } = target;
415
+ const refusal = refuseTarget(host, port);
416
+ if (refusal) {
417
+ record('corridor', normalizeName(host), port, refusal);
418
+ refuse(clientSocket, 403, 'Forbidden', REFUSED);
419
+ return;
420
+ }
421
+ if (openTunnels >= MAX_TUNNELS) {
422
+ record('corridor', normalizeName(host), port, 'deny:too-many-tunnels');
423
+ refuse(clientSocket, 503, 'Service Unavailable', REFUSED);
424
+ return;
425
+ }
426
+ // The place is taken before the name is looked up, not after. Otherwise a burst of clients
427
+ // would all pass the check while they wait for DNS, and the cap would be one in name only.
428
+ openTunnels += 1;
429
+ let counted = true;
430
+ const releasePlace = () => {
431
+ if (counted) {
432
+ counted = false;
433
+ openTunnels -= 1;
434
+ }
435
+ };
436
+ release = releasePlace;
437
+
438
+ // The name that was checked is the name that is resolved. Anything else leaves a gap between
439
+ // the two for input that normalises into something else.
440
+ const resolved = await resolveAllowedAddress(normalizeName(host));
441
+ if (resolved.error) {
442
+ releasePlace();
443
+ record('corridor', normalizeName(host), port, `deny:${resolved.error}`);
444
+ refuse(clientSocket, 403, 'Forbidden', REFUSED);
445
+ return;
446
+ }
447
+ record('corridor', normalizeName(host), port, 'allow');
448
+
449
+ // The connection goes to the address that was just checked. The name is never resolved again.
450
+ // `allowHalfOpen`, because a tunnel passes a close in each direction on its own. Without it
451
+ // Node ends our side to the upstream the moment the upstream sends FIN, and anything the space
452
+ // writes afterwards is lost: measured against the real program, an upstream that half-closes
453
+ // and keeps reading never received what the space sent next. The cost is that a half-open
454
+ // tunnel holds its place until one side closes or the idle timeout ends it, and the tunnel cap
455
+ // bounds how many of those a space can hold.
456
+ const upstream = net.connect({ host: resolved.address, port, allowHalfOpen: true });
457
+ // Pulling the plug: for an error, a timeout, or a client that left before the tunnel existed.
458
+ const abort = () => {
459
+ releasePlace();
460
+ upstream.destroy();
461
+ clientSocket.destroy();
462
+ };
463
+ let carrying = false;
464
+ upstream.on('error', (error) => {
465
+ // Allowed, and the connection out did not happen. The user's view must say that, or an
466
+ // unreachable host reads as a refusal in the journal and nobody can tell them apart.
467
+ const code = String(error.code || 'error').slice(0, 32);
468
+ record('corridor', normalizeName(host), port, `failed:${code}`);
469
+ // Only while the socket still belongs to the proxy. Once the tunnel carries somebody's
470
+ // bytes, writing `HTTP/1.1 502` into them puts proxy protocol in the middle of a TLS
471
+ // stream: a reset halfway through a download would reach the space as a record error
472
+ // instead of a reset. It is the same mistake as the teardown that truncated transfers.
473
+ if (!carrying && !clientSocket.destroyed && !clientSocket.writableEnded) {
474
+ // `refuse` owns the client socket from here: it drains what the space is still sending and
475
+ // closes when the space is done, because closing on a full receive queue sends a reset and
476
+ // the reset takes the answer with it. `abort` would do exactly that in the same tick, and
477
+ // what would be lost is `x-gatekeeper-reason: unreachable` — the one line that tells the
478
+ // space this host was allowed and could not be reached, rather than refused.
479
+ refuse(clientSocket, 502, 'Bad Gateway', UNREACHABLE);
480
+ releasePlace();
481
+ upstream.destroy();
482
+ return;
483
+ }
484
+ abort();
485
+ });
486
+ upstream.setTimeout(CONNECT_TIMEOUT_MS, abort);
487
+ upstream.on('connect', () => {
488
+ upstream.setTimeout(TUNNEL_IDLE_MS, abort);
489
+ clientSocket.setTimeout(TUNNEL_IDLE_MS, abort);
490
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
491
+ if (head && head.length > 0) upstream.write(head);
492
+ // From here the two sockets carry somebody's bytes, and a teardown written for an empty
493
+ // socket would throw them away. `release` was that teardown; it is done now, and so is
494
+ // every answer this proxy is still entitled to write into that socket.
495
+ carrying = true;
496
+ release = () => {};
497
+ joinSockets(clientSocket, upstream, releasePlace);
498
+ });
499
+ release = abort;
500
+ // The client may have left while its name was being resolved.
501
+ if (clientSocket.destroyed || clientSocket.readableEnded) abort();
502
+ }
503
+
504
+ /**
505
+ * Two sockets, joined for as long as they both live, and the place given back when they are
506
+ * both gone.
507
+ *
508
+ * `pipe` is what carries a close across: when one side sends FIN, pipe ends the other side once
509
+ * what is buffered has gone out. Destroying on `end` instead is how a proxy truncates a
510
+ * download, and the larger the answer and the slower the reader, the more of it goes missing.
511
+ * The last draft did exactly that, and nothing in the suite noticed, because every test read the
512
+ * status line and stopped.
513
+ */
514
+ function joinSockets(clientSocket, upstream, done) {
515
+ let closed = 0;
516
+ const bothGone = () => {
517
+ closed += 1;
518
+ if (closed >= 2) done();
519
+ };
520
+ // `pipe` carries a FIN across, and that is what keeps a transfer whole. It cannot carry a
521
+ // death: when one socket is destroyed, pipe unpipes its peer, the peer goes paused, and a
522
+ // paused socket never notices that the other end is gone. So a peer whose partner died is told
523
+ // to go too, with `destroySoon`, which writes out what it still holds before it closes.
524
+ const follow = (peer) => () => {
525
+ if (!peer.destroyed) peer.destroySoon();
526
+ bothGone();
527
+ };
528
+ upstream.on('close', follow(clientSocket));
529
+ clientSocket.on('close', follow(upstream));
530
+ upstream.pipe(clientSocket);
531
+ clientSocket.pipe(upstream);
532
+ }
533
+
534
+ /** Anything but CONNECT. Stage 0 proved that OpenCode, curl, npm and git all use CONNECT for https. */
535
+ function refusePlainProxy(request, response) {
536
+ let host = '';
537
+ try {
538
+ host = normalizeName(new URL(request.url).hostname);
539
+ } catch {
540
+ host = '';
541
+ }
542
+ record('corridor', host, 0, 'deny:not-connect');
543
+ response.writeHead(403, { 'content-type': 'application/json' });
544
+ response.end('{"error":"the corridor takes CONNECT only"}');
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------------------------
548
+ // Window
549
+ //
550
+ // Built for any upstream and any header name, because git over https and a private npm registry
551
+ // go through the same mechanism later. Only the model-provider path is wired and tested here.
552
+ // ---------------------------------------------------------------------------------------------
553
+
554
+ const WINDOW_PATH = /^\/model\/([A-Za-z0-9._-]{1,64})([/?][^]*)?$/;
555
+
556
+ /**
557
+ * Does the rest of a window path walk out of its grant?
558
+ *
559
+ * A grant opens a place on a host, not the host, so a rest that climbs above it has to go. A
560
+ * literal `..` is the least of it: the rest reaches the upstream verbatim, and a server that
561
+ * decodes before it resolves reads `%2e%2e`, `.%2e`, `..%2f`, `..%5c` and `..;` as the same
562
+ * climb. Refusing every rest carrying a `%` would be simpler and would be wrong, because a
563
+ * scoped npm package is fetched as `/@scope%2fname` and that window is half the reason this
564
+ * rule exists. So the rest is decoded until decoding changes nothing, and each round is split on
565
+ * every separator a server might honour before `..` is compared. A rest that will not decode is
566
+ * refused: nothing legitimate sends a `%` that is not an escape, and the alternative is guessing
567
+ * which of two readings the upstream will take.
568
+ *
569
+ * Decoding to a fixed point rather than a fixed number of rounds, because a bound on the rounds
570
+ * is a hole at the round after it: at three, a four-times-encoded `..` went through verbatim.
571
+ * The loop ends on its own — every round that changes anything replaces at least one three-byte
572
+ * escape with one byte, so the rest strictly shortens until a round decodes to itself.
573
+ *
574
+ * Not an escape today — a grant's upstream and its key belong together — and a trap left armed
575
+ * for the stage that puts git and a private registry behind the same window.
576
+ */
577
+ function walksOutOfGrant(rest) {
578
+ let form = String(rest ?? '');
579
+ for (;;) {
580
+ if (form.split(/[/\\;?#]/).includes('..')) return true;
581
+ if (!form.includes('%')) return false;
582
+ let decoded;
583
+ try {
584
+ decoded = decodeURIComponent(form);
585
+ } catch {
586
+ return true;
587
+ }
588
+ if (decoded === form) return false;
589
+ form = decoded;
590
+ }
591
+ }
592
+
593
+ // The window's own sockets out, capped the same way its sockets in are. Requests past the cap
594
+ // wait for a socket instead of opening one, and the listener's own cap bounds how many can wait.
595
+ const agentOptions = { keepAlive: false, maxSockets: MAX_WINDOW_CONNECTIONS, maxTotalSockets: MAX_WINDOW_CONNECTIONS };
596
+ const windowAgents = { 'http:': new http.Agent(agentOptions), 'https:': new https.Agent(agentOptions) };
597
+
598
+ const answerJson = (response, status, body) => {
599
+ response.writeHead(status, { 'content-type': 'application/json' });
600
+ response.end(JSON.stringify(body));
601
+ };
602
+
603
+ /**
604
+ * The gatekeeper's own listeners are not an upstream. A grant is the host's decision, and the
605
+ * user's private network is a legitimate place for one, so only this refusal stands in the way.
606
+ *
607
+ * Every address this container has, not only loopback: the corridor and the window bind
608
+ * `0.0.0.0`, so the address the space reaches them on is the one on the inner network, and a
609
+ * grant pointing there would feed the window into itself until its 64 sockets out were gone.
610
+ */
611
+ const ownListenerPorts = new Set([Number(corridorPort), Number(windowPort), Number(controlPort)]);
612
+ const ownAddresses = new Set(Object.values(os.networkInterfaces())
613
+ .flat()
614
+ .filter(Boolean)
615
+ .map((entry) => String(entry.address).toLowerCase()));
616
+
617
+ function isLoopback(address) {
618
+ const v4 = parseIPv4(address);
619
+ if (v4) return v4[0] === 127;
620
+ const v6 = parseIPv6(address);
621
+ if (!v6) return false;
622
+ const embedded = embeddedIPv4(v6);
623
+ if (embedded) return embedded[0] === 127;
624
+ return v6.slice(0, 15).every((byte) => byte === 0) && v6[15] === 1;
625
+ }
626
+
627
+ const isOwnListener = (address, port) => (isLoopback(address) || ownAddresses.has(String(address).toLowerCase())) && ownListenerPorts.has(Number(port));
628
+
629
+ async function serveWindow(request, response) {
630
+ request.on('error', () => {});
631
+ const match = WINDOW_PATH.exec(request.url || '');
632
+ if (match && walksOutOfGrant(match[2] || '')) {
633
+ // Its own decision, because the journal is the user's only view: "tried to walk out of its
634
+ // path" and "guessed a grant id" are different things and should not read as one. The answer
635
+ // to the space stays identical to the one below, so it cannot sort a real id from a wrong one.
636
+ record('window', 'grant-path', 0, 'deny:path-walks-out');
637
+ answerJson(response, 403, { error: 'no such grant' });
638
+ return;
639
+ }
640
+ const grant = match ? grants.get(match[1]) : null;
641
+ if (!grant) {
642
+ // The id is not a secret, and the journal keeps no path, so the destination is all it says.
643
+ record('window', 'unknown-grant', 0, 'deny:no-grant');
644
+ answerJson(response, 403, { error: 'no such grant' });
645
+ return;
646
+ }
647
+
648
+ const upstream = new URL(grant.upstream);
649
+ const port = Number(upstream.port || (upstream.protocol === 'https:' ? 443 : 80));
650
+ if (ALWAYS_REFUSED.includes(normalizeName(upstream.hostname))) {
651
+ record('window', normalizeName(upstream.hostname), port, 'deny:always-refused');
652
+ answerJson(response, 403, { error: 'refused destination' });
653
+ return;
654
+ }
655
+ const resolved = await lookupAll(upstream.hostname);
656
+ if (resolved.addresses.length === 0) {
657
+ record('window', normalizeName(upstream.hostname), port, `deny:unresolved:${resolved.reason}`);
658
+ answerJson(response, 502, { error: 'the upstream of this grant does not resolve' });
659
+ return;
660
+ }
661
+ const chosen = chooseAddress(resolved.addresses);
662
+ if (isOwnListener(chosen.address, port)) {
663
+ record('window', normalizeName(upstream.hostname), port, 'deny:own-listener');
664
+ answerJson(response, 403, { error: 'refused destination' });
665
+ return;
666
+ }
667
+
668
+ // Whatever the space sent as credentials goes, and the grant's own header takes its place.
669
+ const headers = { ...request.headers };
670
+ for (const name of Object.keys(headers)) {
671
+ if (['authorization', 'x-api-key', 'proxy-authorization'].includes(name.toLowerCase())) delete headers[name];
672
+ }
673
+ headers.host = upstream.host;
674
+ headers[grant.header] = grant.header.toLowerCase() === 'authorization' ? `Bearer ${grant.secret}` : grant.secret;
675
+
676
+ record('window', normalizeName(upstream.hostname), port, 'allow');
677
+ const transport = upstream.protocol === 'https:' ? https : http;
678
+ const outgoing = transport.request({
679
+ hostname: upstream.hostname,
680
+ port,
681
+ method: request.method,
682
+ path: upstream.pathname.replace(/\/$/, '') + (match[2] || ''),
683
+ headers,
684
+ agent: windowAgents[upstream.protocol],
685
+ // The address was checked a moment ago, and this is the address the request goes to.
686
+ lookup: fixedLookup(chosen.address, chosen.family),
687
+ }, (answer) => {
688
+ response.writeHead(answer.statusCode || 502, answer.headers);
689
+ answer.on('error', () => { response.destroy(); });
690
+ answer.pipe(response);
691
+ });
692
+ // The deadline covers the whole exchange, silence by silence. `given` keeps the record to one
693
+ // per request: the destroy below makes the socket error, and that is this timeout, not news.
694
+ let given = false;
695
+ outgoing.setTimeout(WINDOW_IDLE_MS, () => {
696
+ given = true;
697
+ record('window', normalizeName(upstream.hostname), port, 'failed:timeout');
698
+ if (!response.headersSent) answerJson(response, 504, { error: 'the upstream did not answer in time' });
699
+ else response.destroy();
700
+ outgoing.destroy();
701
+ });
702
+ outgoing.on('error', (error) => {
703
+ if (given) return;
704
+ given = true;
705
+ // Allowed, and the connection out did not happen, which is not the same as a refusal.
706
+ // The message can hold the upstream's name, never a header or a body.
707
+ const code = String(error.code || 'error').slice(0, 32);
708
+ record('window', normalizeName(upstream.hostname), port, `failed:${code}`);
709
+ if (!response.headersSent) answerJson(response, 502, { error: 'the upstream did not answer' });
710
+ else response.destroy();
711
+ });
712
+ response.on('close', () => {
713
+ // A finished answer has already ended this socket; destroying then would be the same defect
714
+ // the corridor had. This is for an answer that stopped short, where the client went away.
715
+ if (!response.writableFinished) outgoing.destroy();
716
+ });
717
+ request.pipe(outgoing);
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------------------------
721
+ // Control, on loopback only
722
+ // ---------------------------------------------------------------------------------------------
723
+
724
+ const readBody = (request) => new Promise((resolve) => {
725
+ let text = '';
726
+ let tooLarge = false;
727
+ request.on('data', (chunk) => {
728
+ if (tooLarge) return;
729
+ text += chunk;
730
+ if (text.length > CONTROL_BODY_BYTES) {
731
+ tooLarge = true;
732
+ text = '';
733
+ }
734
+ });
735
+ request.on('error', () => resolve(null));
736
+ request.on('end', () => resolve(tooLarge ? null : text));
737
+ });
738
+
739
+ /**
740
+ * One field of a control request as text. A name, a header name and a secret are text; a list,
741
+ * an object or nothing at all is none of those, and comes out as the empty string that every
742
+ * check below refuses.
743
+ */
744
+ const asText = (value) => (value === null || value === undefined || value instanceof Object ? '' : String(value));
745
+
746
+ function setNetwork(body) {
747
+ if (body.mode !== 'allowlist' && body.mode !== 'open') return 'mode is "allowlist" or "open"';
748
+ const domains = Array.isArray(body.domains) ? body.domains : [];
749
+ if (domains.length > MAX_ALLOWED_DOMAINS) return `at most ${MAX_ALLOWED_DOMAINS} domains`;
750
+ const names = domains.map((domain) => normalizeName(asText(domain)));
751
+ // The same rule a target passes, so the host never puts an entry on the list that the corridor
752
+ // would always refuse. An address on it would be a way around the ranges this refuses anyway.
753
+ const wrong = names.find((name) => !isDomainName(name) || parseIPv4(name) || parseIPv6(name));
754
+ if (wrong !== undefined) return `'${wrong}' is not a domain name. The allowlist holds exact names, no wildcards and no addresses.`;
755
+ networkMode = body.mode;
756
+ allowedDomains = new Set(names);
757
+ return null;
758
+ }
759
+
760
+ function addGrant(body) {
761
+ const id = asText(body.id);
762
+ const header = asText(body.header);
763
+ const secret = asText(body.secret);
764
+ if (!GRANT_ID_PATTERN.test(id)) return 'a grant id is 1 to 64 letters, digits, dots, dashes or underscores';
765
+ if (!HEADER_NAME_PATTERN.test(header)) return 'a header name is 1 to 64 letters, digits or dashes';
766
+ if (secret.length === 0 || secret.length > MAX_SECRET_LENGTH) return 'a grant needs a secret';
767
+ let upstream;
768
+ try {
769
+ upstream = new URL(asText(body.upstream));
770
+ } catch {
771
+ return 'the upstream of a grant is a URL';
772
+ }
773
+ if (upstream.protocol !== 'http:' && upstream.protocol !== 'https:') return 'the upstream of a grant is http or https';
774
+ grants.set(id, { upstream: upstream.toString(), header, secret });
775
+ return null;
776
+ }
777
+
778
+ async function serveControl(request, response) {
779
+ request.on('error', () => {});
780
+ const path = String(request.url || '').split('?')[0];
781
+ if (request.method === 'GET' && path === '/health') {
782
+ answerJson(response, 200, { ready: true, mode: networkMode, domains: allowedDomains.size, grants: grants.size });
783
+ return;
784
+ }
785
+ if (request.method === 'GET' && path === '/journal') {
786
+ answerJson(response, 200, { records: journal.slice(), dropped: journalDropped });
787
+ return;
788
+ }
789
+ if (request.method !== 'POST' || (path !== '/network' && path !== '/grants')) {
790
+ answerJson(response, 404, { error: 'no such control endpoint' });
791
+ return;
792
+ }
793
+ const text = await readBody(request);
794
+ let body = null;
795
+ try {
796
+ body = JSON.parse(text ?? '');
797
+ } catch {
798
+ answerJson(response, 400, { error: 'the body is not JSON' });
799
+ return;
800
+ }
801
+ if (!(body instanceof Object) || Array.isArray(body)) {
802
+ answerJson(response, 400, { error: 'the body is not an object' });
803
+ return;
804
+ }
805
+ const problem = path === '/network' ? setNetwork(body) : addGrant(body);
806
+ if (problem) answerJson(response, 400, { error: problem });
807
+ else answerJson(response, 200, { ok: true });
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------------------------
811
+ // Start
812
+ // ---------------------------------------------------------------------------------------------
813
+
814
+ let listening = false;
815
+ const lastNote = new Map();
816
+
817
+ /**
818
+ * Traffic that never reached a decision: a connection the socket cap refused, or a request the
819
+ * HTTP parser threw out. Recorded at most once a minute per listener and per kind, because the
820
+ * flood is the point of the record and 500 notes of it would erase everything else.
821
+ */
822
+ function noteRefusedTraffic(listener, decision) {
823
+ const key = `${listener}:${decision}`;
824
+ const last = lastNote.get(key) ?? 0;
825
+ if (Date.now() - last < NOTE_INTERVAL_MS) return;
826
+ lastNote.set(key, Date.now());
827
+ record(listener, '', 0, decision);
828
+ }
829
+
830
+ function makeServer(listener, maxConnections, handler) {
831
+ const server = http.createServer({ maxHeaderSize: MAX_HEADER_BYTES }, handler);
832
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
833
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
834
+ // Sockets, not requests. Without this the space can hold thousands of connections open in the
835
+ // header window and have the process killed for its memory, which would take the journal too.
836
+ server.maxConnections = maxConnections;
837
+ server.on('drop', () => noteRefusedTraffic(listener, 'deny:too-many-connections'));
838
+ // A client that sends nonsense gets an answer and its own socket destroyed, and no other client
839
+ // notices. It is journalled the same way a refused connection is, rate-limited: a request the
840
+ // parser threw out never reaches a decision, and without a record here a space could probe with
841
+ // targets the parser rejects and leave the user's view of its attempts blind by construction.
842
+ server.on('clientError', (error, socket) => {
843
+ noteRefusedTraffic(listener, 'deny:unreadable-request');
844
+ if (!socket.destroyed && socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
845
+ socket.destroy();
846
+ });
847
+ server.on('error', (error) => {
848
+ // A port that cannot be taken must end the start. Afterwards, a failed accept is the
849
+ // space's doing, for example by running the process out of file descriptors, and
850
+ // ending the gatekeeper on that would be a denial of service it can trigger at will.
851
+ console.error(`gatekeeper: listener error ${error.code || error.message}`);
852
+ if (!listening) process.exit(1);
853
+ });
854
+ return server;
855
+ }
856
+
857
+ const corridor = makeServer('corridor', MAX_CORRIDOR_CONNECTIONS, refusePlainProxy);
858
+ corridor.on('connect', (request, socket, head) => {
859
+ openTunnel(request, socket, head).catch(() => { socket.destroy(); });
860
+ });
861
+ const windowServer = makeServer('window', MAX_WINDOW_CONNECTIONS, (request, response) => {
862
+ serveWindow(request, response).catch(() => {
863
+ if (!response.headersSent) answerJson(response, 502, { error: 'the window could not serve this request' });
864
+ else response.destroy();
865
+ });
866
+ });
867
+ const control = makeServer('control', MAX_CONTROL_CONNECTIONS, (request, response) => {
868
+ serveControl(request, response).catch(() => {
869
+ if (!response.headersSent) answerJson(response, 500, { error: 'the control channel could not serve this request' });
870
+ else response.destroy();
871
+ });
872
+ });
873
+
874
+ const listen = (server, port, host) => new Promise((resolve) => { server.listen(Number(port), host, resolve); });
875
+
876
+ async function main() {
877
+ if (!bindHost || !corridorPort || !windowPort || !controlPort) {
878
+ console.error('gatekeeper: arguments are <bind host> <corridor port> <window port> <control port>');
879
+ process.exit(2);
880
+ }
881
+ await listen(corridor, corridorPort, bindHost);
882
+ await listen(windowServer, windowPort, bindHost);
883
+ // Last, and on loopback only: an answer from here means all three are up.
884
+ await listen(control, controlPort, CONTROL_HOST);
885
+ listening = true;
886
+ // Only now. A listener that cannot bind must still end the process.
887
+ //
888
+ // What this line may say is limited on purpose. An error's message can carry what the space
889
+ // sent, a header value or a piece of a body among it, and the container log is not a place for
890
+ // that. The name, the code and the frame where it happened say where to look without it.
891
+ process.on('uncaughtException', (error) => {
892
+ console.error(`gatekeeper: kept running after ${describeFailure(error)}`);
893
+ });
894
+ process.on('unhandledRejection', (reason) => {
895
+ console.error(`gatekeeper: kept running after a rejected promise, ${describeFailure(reason)}`);
896
+ });
897
+ console.log(`gatekeeper: corridor ${corridorPort}, window ${windowPort}, control ${CONTROL_HOST}:${controlPort}`);
898
+ }
899
+
900
+ /** The kind of failure and where it happened, and nothing that came from outside. */
901
+ function describeFailure(error) {
902
+ if (!(error instanceof Error)) return 'something that is not an error';
903
+ const frame = String(error.stack ?? '').split('\n').find((line) => line.trim().startsWith('at ')) ?? '';
904
+ return `${error.name}${error.code ? ` (${error.code})` : ''}${frame ? ` ${frame.trim()}` : ''}`;
905
+ }
906
+
907
+ // The container runs this file as its main module. A test can require it instead and call the
908
+ // rules directly, so every range and every target form is covered without a listener and without
909
+ // a switch that changes a decision.
910
+ if (require.main === module) {
911
+ main();
912
+ } else {
913
+ module.exports = { chooseAddress, isBlockedAddress, isDomainName, joinSockets, parseIPv4, parseIPv6, refuseTarget, setNetwork, splitTarget, walksOutOfGrant };
914
+ }