@vantran-se/openchamber-web 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +4 -4
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -6,6 +6,7 @@
6
6
  // curl config. It is in no argument list, on the host or inside, in no label and in no file.
7
7
 
8
8
  import { readFileSync } from 'node:fs';
9
+ import net from 'node:net';
9
10
 
10
11
  import { SpaceError } from './errors.js';
11
12
  import {
@@ -19,6 +20,7 @@ import {
19
20
  } from './exec-http.js';
20
21
  import { ROLE_GATEKEEPER } from './labels.js';
21
22
  import {
23
+ GATEKEEPER_BIND_PATH,
22
24
  GATEKEEPER_CONTROL_HOST,
23
25
  GATEKEEPER_CONTROL_PORT,
24
26
  GATEKEEPER_PROGRAM_DIRECTORY,
@@ -46,13 +48,19 @@ const MAX_JOURNAL_RECORDS = 1_000;
46
48
  const MAX_JOURNAL_BODY_CHARACTERS = 1024 * 1024;
47
49
  const MAX_JOURNAL_FIELD_CHARACTERS = 256;
48
50
 
49
- // The program goes through a temporary name, so the waiting container never runs half a file.
51
+ // The bind address first, then the program, each through a temporary name, so the waiting
52
+ // container never runs half a file and never runs the program before its address is there.
50
53
  const WRITE_PROGRAM_SCRIPT = [
51
54
  IMAGE_ONLY_PATH,
52
55
  `mkdir -p ${GATEKEEPER_PROGRAM_DIRECTORY}`,
56
+ `&& printf '%s' "$1" > ${GATEKEEPER_BIND_PATH}.new && mv ${GATEKEEPER_BIND_PATH}.new ${GATEKEEPER_BIND_PATH}`,
53
57
  `&& cat > ${GATEKEEPER_PROGRAM_PATH}.new && mv ${GATEKEEPER_PROGRAM_PATH}.new ${GATEKEEPER_PROGRAM_PATH}`,
54
58
  ].join(' ');
55
59
 
60
+ // A policy is three small lists. These caps are what the host is willing to read back.
61
+ const MAX_POLICY_BODY_CHARACTERS = 256 * 1024;
62
+ const MAX_POLICY_ENTRIES = 1_000;
63
+
56
64
  const pause = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
57
65
 
58
66
  const unreadableAnswer = (why) => new SpaceError('gatekeeper_answer_unreadable', `The gatekeeper of the space ${why}`);
@@ -73,9 +81,18 @@ const text = (value) => (value === null || value === undefined || value instance
73
81
  export function createGatekeeperChannel({ exec, wait = pause, now = Date.now }) {
74
82
  const execInGatekeeper = (spaceId, argv, options) => exec(spaceId, argv, { ...options, target: ROLE_GATEKEEPER });
75
83
 
76
- /** The program travels on stdin. It is in no argument, on the host or inside. */
77
- const writeProgram = async (spaceId) => {
78
- const result = await execInGatekeeper(spaceId, [IMAGE_SH, '-c', WRITE_PROGRAM_SCRIPT], { stdin: GATEKEEPER_PROGRAM });
84
+ /**
85
+ * The program travels on stdin. It is in no argument, on the host or inside. `bindAddress` is
86
+ * the gatekeeper's own address on the space's network, which the place read from the runtime
87
+ * after the container started; it goes into the file the container command names, and the
88
+ * program listens there and nowhere else. It is an address and not a secret, so it may be an
89
+ * argument of the script.
90
+ */
91
+ const writeProgram = async (spaceId, { bindAddress }) => {
92
+ if (net.isIP(bindAddress) === 0 || bindAddress === '0.0.0.0' || bindAddress === '::') {
93
+ throw new SpaceError('gatekeeper_address_unknown', 'The address of the gatekeeper on the space\'s network is not known, so its listeners cannot be bound to it.');
94
+ }
95
+ const result = await execInGatekeeper(spaceId, [IMAGE_SH, '-c', WRITE_PROGRAM_SCRIPT, 'sh', bindAddress], { stdin: GATEKEEPER_PROGRAM });
79
96
  if (result.code !== 0) {
80
97
  throw new SpaceError('gatekeeper_setup_failed', `Could not store the gatekeeper's program: ${tail(result.stderr) || `exit code ${result.code}`}`);
81
98
  }
@@ -157,8 +174,43 @@ export function createGatekeeperChannel({ exec, wait = pause, now = Date.now })
157
174
  */
158
175
  const setNetwork = (spaceId, { mode, domains = [] }) => command(spaceId, '/network', { mode, domains }, 'change the network of this space');
159
176
 
160
- /** One "uses without seeing" grant. The secret is in the body, so it stays out of every argument list. */
161
- const addGrant = (spaceId, { id, upstream, header, secret }) => command(spaceId, '/grants', { id, upstream, header, secret }, `add the grant '${id}'`);
177
+ /**
178
+ * One grant: a "uses without seeing" credential, whose secret is in the body and so out of every
179
+ * argument list, or an opened domain with no secret and no header, which the window forwards to
180
+ * as the space sent it.
181
+ */
182
+ const addGrant = (spaceId, { id, upstream, header = null, secret = null }) => command(
183
+ spaceId,
184
+ '/grants',
185
+ secret === null ? { id, upstream } : { id, upstream, header, secret },
186
+ `add the grant '${id}'`,
187
+ );
188
+
189
+ /**
190
+ * What the gatekeeper holds now: its mode, its allowlist and the ids of its grants, never a
191
+ * secret. Read as data, capped, so the host can tell a gatekeeper that forgot its grants after
192
+ * a start from one that has them.
193
+ */
194
+ const readPolicy = async (spaceId) => {
195
+ const answer = await request(spaceId, { path: '/policy' });
196
+ if (answer.status !== 200) throw unreadableAnswer(`answered ${answer.status} for its policy`);
197
+ if (answer.body.length > MAX_POLICY_BODY_CHARACTERS) throw unreadableAnswer('answered with a policy larger than the host reads');
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(answer.body);
201
+ } catch {
202
+ throw unreadableAnswer('answered with a policy that is not JSON');
203
+ }
204
+ if (!(parsed instanceof Object) || Array.isArray(parsed) || !Array.isArray(parsed.grants) || !Array.isArray(parsed.domains)) {
205
+ throw unreadableAnswer('answered with a policy that holds no lists');
206
+ }
207
+ const names = (list) => list.slice(0, MAX_POLICY_ENTRIES).map(text).filter((entry) => entry !== '');
208
+ return {
209
+ mode: parsed.mode === 'open' ? 'open' : 'allowlist',
210
+ domains: names(parsed.domains),
211
+ grants: names(parsed.grants),
212
+ };
213
+ };
162
214
 
163
215
  /**
164
216
  * What the gatekeeper allowed and refused. The answer comes out of a container, so it is read
@@ -195,8 +247,10 @@ export function createGatekeeperChannel({ exec, wait = pause, now = Date.now })
195
247
  };
196
248
  }),
197
249
  dropped: Number.isSafeInteger(parsed.dropped) && parsed.dropped >= 0 ? parsed.dropped : 0,
250
+ // When the gatekeeper started: the journal holds nothing older, and the UI has to say so.
251
+ since: text(parsed.since),
198
252
  };
199
253
  };
200
254
 
201
- return { writeProgram, waitUntilReady, setNetwork, addGrant, readJournal };
255
+ return { writeProgram, waitUntilReady, setNetwork, addGrant, readPolicy, readJournal };
202
256
  }
@@ -31,23 +31,33 @@ describe('gatekeeper channel: the program', () => {
31
31
  expect(GATEKEEPER_PROGRAM.length).toBeGreaterThan(2_000);
32
32
  });
33
33
 
34
- it('travels on stdin into the tmpfs, through a temporary name, and is in no argument', async () => {
34
+ it('travels on stdin into the tmpfs, through a temporary name, after the bind address, and is in no argument', async () => {
35
35
  const { channel, calls } = channelWith(ok());
36
- await channel.writeProgram(ID);
36
+ await channel.writeProgram(ID, { bindAddress: '172.19.0.2' });
37
37
 
38
38
  expect(calls).toEqual([{
39
39
  spaceId: ID,
40
40
  argv: [
41
41
  '/bin/sh', '-c',
42
- 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; mkdir -p /tmp/openchamber-gatekeeper && cat > /tmp/openchamber-gatekeeper/gatekeeper.cjs.new && mv /tmp/openchamber-gatekeeper/gatekeeper.cjs.new /tmp/openchamber-gatekeeper/gatekeeper.cjs',
42
+ 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; mkdir -p /tmp/openchamber-gatekeeper'
43
+ + " && printf '%s' \"$1\" > /tmp/openchamber-gatekeeper/bind.new && mv /tmp/openchamber-gatekeeper/bind.new /tmp/openchamber-gatekeeper/bind"
44
+ + ' && cat > /tmp/openchamber-gatekeeper/gatekeeper.cjs.new && mv /tmp/openchamber-gatekeeper/gatekeeper.cjs.new /tmp/openchamber-gatekeeper/gatekeeper.cjs',
45
+ 'sh', '172.19.0.2',
43
46
  ],
44
47
  options: { stdin: GATEKEEPER_PROGRAM, target: 'gatekeeper' },
45
48
  }]);
46
49
  });
47
50
 
51
+ // The program refuses these too; the host refuses them first, so a gatekeeper is never asked to listen everywhere.
52
+ it.each(['', '0.0.0.0', '::', 'gatekeeper', '172.19.0'])('refuses to write the program with %j as the bind address', async (bindAddress) => {
53
+ const { channel, calls } = channelWith(ok());
54
+ await expect(channel.writeProgram(ID, { bindAddress })).rejects.toMatchObject({ code: 'gatekeeper_address_unknown' });
55
+ expect(calls).toEqual([]);
56
+ });
57
+
48
58
  it('says what failed inside the gatekeeper', async () => {
49
59
  const { channel } = channelWith({ code: 1, stdout: '', stderr: 'sh: 1: cannot create: No space left on device\n' });
50
- await expect(channel.writeProgram(ID)).rejects.toMatchObject({ code: 'gatekeeper_setup_failed', message: expect.stringContaining('No space left on device') });
60
+ await expect(channel.writeProgram(ID, { bindAddress: '172.19.0.2' })).rejects.toMatchObject({ code: 'gatekeeper_setup_failed', message: expect.stringContaining('No space left on device') });
51
61
  });
52
62
  });
53
63
 
@@ -76,6 +86,26 @@ describe('gatekeeper channel: control requests', () => {
76
86
  expect(calls[0].options.stdin).toContain(SECRET);
77
87
  });
78
88
 
89
+ it('sends an opened domain as a grant with no header and no secret', async () => {
90
+ const { channel, calls } = channelWith(ok(http(200, '{"ok":true}')));
91
+ await channel.addGrant(ID, { id: 'open-0a1b2c3d4e5f', upstream: 'https://registry.example.com/npm/' });
92
+ const data = calls[0].options.stdin.split('\n').find((line) => line.startsWith('data-raw = '));
93
+ expect(JSON.parse(JSON.parse(data.slice('data-raw = '.length)))).toEqual({ id: 'open-0a1b2c3d4e5f', upstream: 'https://registry.example.com/npm/' });
94
+ });
95
+
96
+ it('reads what the gatekeeper holds as data: mode, names and grant ids, capped, and never a secret', async () => {
97
+ const { channel } = channelWith(ok(http(200, JSON.stringify({ mode: 'open', domains: ['a.example.com', 7, {}], grants: ['anthropic', ['x'], 'open-1'], secret: SECRET }))));
98
+ expect(await channel.readPolicy(ID)).toEqual({ mode: 'open', domains: ['a.example.com', '7'], grants: ['anthropic', 'open-1'] });
99
+ const { channel: allowlist } = channelWith(ok(http(200, '{"mode":"anything","domains":[],"grants":[]}')));
100
+ expect(await allowlist.readPolicy(ID)).toEqual({ mode: 'allowlist', domains: [], grants: [] });
101
+ for (const body of ['not json', '[]', '{"grants":"anthropic"}', '{"grants":[]}']) {
102
+ const { channel: broken } = channelWith(ok(http(200, body)));
103
+ await expect(broken.readPolicy(ID)).rejects.toMatchObject({ code: 'gatekeeper_answer_unreadable' });
104
+ }
105
+ const { channel: refused } = channelWith(ok(http(404, '{}')));
106
+ await expect(refused.readPolicy(ID)).rejects.toMatchObject({ code: 'gatekeeper_answer_unreadable' });
107
+ });
108
+
79
109
  it('reports a refusal from the control channel, with what it said', async () => {
80
110
  const { channel } = channelWith(ok(http(400, '{"error":"mode is \\"allowlist\\" or \\"open\\""}')));
81
111
  await expect(channel.setNetwork(ID, { mode: 'everything', domains: [] })).rejects.toMatchObject({
@@ -119,9 +149,14 @@ describe('gatekeeper channel: control requests', () => {
119
149
  describe('gatekeeper channel: journal', () => {
120
150
  const record = { at: '2026-09-20T10:00:00.000Z', listener: 'corridor', host: 'api.anthropic.com', port: 443, decision: 'allow' };
121
151
 
122
- it('reads the records and how many the ring buffer dropped', async () => {
123
- const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [record], dropped: 12 }))));
124
- expect(await channel.readJournal(ID)).toEqual({ records: [record], dropped: 12 });
152
+ it('reads the records, how many the ring buffer dropped, and since when the gatekeeper records', async () => {
153
+ const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [record], dropped: 12, since: '2026-09-20T09:00:00.000Z' }))));
154
+ expect(await channel.readJournal(ID)).toEqual({ records: [record], dropped: 12, since: '2026-09-20T09:00:00.000Z' });
155
+ });
156
+
157
+ it('leaves "since" empty for a gatekeeper that does not say, and never takes an object for it', async () => {
158
+ const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [], dropped: 0, since: { at: 1 } }))));
159
+ expect(await channel.readJournal(ID)).toEqual({ records: [], dropped: 0, since: '' });
125
160
  });
126
161
 
127
162
  it('keeps the five fields of a record and nothing else the gatekeeper sends', async () => {
@@ -168,6 +203,7 @@ describe('gatekeeper channel: journal', () => {
168
203
  { at: '', listener: '', host: '', port: 0, decision: '' },
169
204
  ],
170
205
  dropped: 0,
206
+ since: '',
171
207
  });
172
208
  });
173
209
  });
@@ -14,18 +14,22 @@
14
14
  // Arguments: <space-facing bind host> <corridor port> <window port> <control port>
15
15
  // [<window deadline in ms>]. They are arguments so that the container command and the tests run
16
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.
17
+ // minutes for it. None of them changes a decision this program makes. The bind host is an
18
+ // address, or the path of a file that holds one: the container command names the file, and the
19
+ // host writes the gatekeeper's own address on the space's network into it before the program,
20
+ // because the host knows that address and this program has no say in where it listens.
18
21
  //
19
22
  // Everything the space sends is hostile input. Nothing it can send may end the process:
20
23
  // a crash of the gatekeeper is a denial of service the agent could trigger at will.
21
24
 
22
25
  const dns = require('node:dns');
26
+ const fs = require('node:fs');
23
27
  const http = require('node:http');
24
28
  const https = require('node:https');
25
29
  const net = require('node:net');
26
30
  const os = require('node:os');
27
31
 
28
- const [bindHost, corridorPort, windowPort, controlPort, windowDeadline, corridorCap, windowCap, controlCap] = process.argv.slice(2);
32
+ const [bindArgument, corridorPort, windowPort, controlPort, windowDeadline, corridorCap, windowCap, controlCap] = process.argv.slice(2);
29
33
 
30
34
  /**
31
35
  * A cap given on the command line, or the production number when there is none.
@@ -105,12 +109,42 @@ const GRANT_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
105
109
  const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
106
110
  const MAX_SECRET_LENGTH = 8192;
107
111
 
112
+ /**
113
+ * The address the corridor and the window listen on. An argument that starts with `/` is the
114
+ * file the host wrote beside this program, holding the gatekeeper's own address on the space's
115
+ * network; anything else is the address itself, which is what the tests pass. Whichever it is,
116
+ * it must be the address of one interface: a file that is missing or empty, or anything that is
117
+ * not an address or names every interface, ends the program. On a Linux Docker host every local
118
+ * process can reach the bridge, and a window that listens there spends the user's key for anyone
119
+ * on the machine. That includes a gatekeeper made before stage 5b, whose command still says
120
+ * `0.0.0.0` and whose checker never compared the command: it no longer starts, and such a
121
+ * space has to be made again. Failing to start is the safe answer.
122
+ */
123
+ function readBindHost(argument) {
124
+ const given = String(argument ?? '');
125
+ let text = given;
126
+ if (given.startsWith('/')) {
127
+ try {
128
+ text = fs.readFileSync(given, 'utf8').trim();
129
+ } catch (error) {
130
+ console.error(`gatekeeper: cannot read the bind address from ${given} (${error.code || error.message})`);
131
+ process.exit(2);
132
+ }
133
+ }
134
+ if (net.isIP(text) === 0 || text === '0.0.0.0' || text === '::') {
135
+ console.error(`gatekeeper: ${given} is not the address of one interface`);
136
+ process.exit(2);
137
+ }
138
+ return text;
139
+ }
108
140
  // Everything the host sets, in memory only. Never a file, never an environment variable,
109
141
  // never an argument, never a label. A restart starts from "nothing is allowed".
110
142
  let networkMode = 'allowlist';
111
143
  let allowedDomains = new Set();
112
144
  const grants = new Map();
113
145
 
146
+ // When this program started, for the journal: nothing before it is on record.
147
+ const STARTED_AT = new Date().toISOString();
114
148
  const journal = [];
115
149
  let journalDropped = 0;
116
150
 
@@ -604,9 +638,9 @@ const answerJson = (response, status, body) => {
604
638
  * The gatekeeper's own listeners are not an upstream. A grant is the host's decision, and the
605
639
  * user's private network is a legitimate place for one, so only this refusal stands in the way.
606
640
  *
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.
641
+ * Every address this container has, not only loopback: the corridor and the window listen on
642
+ * the gatekeeper's address on the inner network, and a grant pointing there, or at any other
643
+ * address of its own, would feed the window into itself until its 64 sockets out were gone.
610
644
  */
611
645
  const ownListenerPorts = new Set([Number(corridorPort), Number(windowPort), Number(controlPort)]);
612
646
  const ownAddresses = new Set(Object.values(os.networkInterfaces())
@@ -665,13 +699,18 @@ async function serveWindow(request, response) {
665
699
  return;
666
700
  }
667
701
 
668
- // Whatever the space sent as credentials goes, and the grant's own header takes its place.
702
+ // Whatever the space sent as credentials goes, and the grant's own header takes its place. A
703
+ // grant without a secret, an opened domain, adds nothing: the upstream sees the request as the
704
+ // space made it, less the credential headers.
669
705
  const headers = { ...request.headers };
670
706
  for (const name of Object.keys(headers)) {
671
- if (['authorization', 'x-api-key', 'proxy-authorization'].includes(name.toLowerCase())) delete headers[name];
707
+ const lower = name.toLowerCase();
708
+ if (['authorization', 'x-api-key', 'proxy-authorization'].includes(lower) || lower === grant.header) delete headers[name];
672
709
  }
673
710
  headers.host = upstream.host;
674
- headers[grant.header] = grant.header.toLowerCase() === 'authorization' ? `Bearer ${grant.secret}` : grant.secret;
711
+ if (grant.secret !== null) {
712
+ headers[grant.header] = grant.header === 'authorization' ? `Bearer ${grant.secret}` : grant.secret;
713
+ }
675
714
 
676
715
  record('window', normalizeName(upstream.hostname), port, 'allow');
677
716
  const transport = upstream.protocol === 'https:' ? https : http;
@@ -757,13 +796,19 @@ function setNetwork(body) {
757
796
  return null;
758
797
  }
759
798
 
799
+ /**
800
+ * A grant with a secret names the header that carries it; a grant without one, an opened domain,
801
+ * names no header and the window adds nothing. A body with a header and no secret, or a secret
802
+ * and no header, is refused: either the grant hides a credential or it does not.
803
+ */
760
804
  function addGrant(body) {
761
805
  const id = asText(body.id);
762
- const header = asText(body.header);
763
- const secret = asText(body.secret);
764
806
  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';
807
+ const withSecret = body.secret !== undefined || body.header !== undefined;
808
+ const header = withSecret ? asText(body.header).toLowerCase() : null;
809
+ const secret = withSecret ? asText(body.secret) : null;
810
+ if (withSecret && !HEADER_NAME_PATTERN.test(header)) return 'a header name is 1 to 64 letters, digits or dashes';
811
+ if (withSecret && (secret.length === 0 || secret.length > MAX_SECRET_LENGTH)) return 'a grant with a header needs a secret';
767
812
  let upstream;
768
813
  try {
769
814
  upstream = new URL(asText(body.upstream));
@@ -782,8 +827,15 @@ async function serveControl(request, response) {
782
827
  answerJson(response, 200, { ready: true, mode: networkMode, domains: allowedDomains.size, grants: grants.size });
783
828
  return;
784
829
  }
830
+ if (request.method === 'GET' && path === '/policy') {
831
+ // What the host last said, so it can tell a gatekeeper that forgot from one that remembers.
832
+ // Ids and names only: a secret is never read back, not even by the host.
833
+ answerJson(response, 200, { mode: networkMode, domains: Array.from(allowedDomains), grants: Array.from(grants.keys()) });
834
+ return;
835
+ }
785
836
  if (request.method === 'GET' && path === '/journal') {
786
- answerJson(response, 200, { records: journal.slice(), dropped: journalDropped });
837
+ // Since when: the journal is memory only, so the host says that nothing older exists.
838
+ answerJson(response, 200, { records: journal.slice(), dropped: journalDropped, since: STARTED_AT });
787
839
  return;
788
840
  }
789
841
  if (request.method !== 'POST' || (path !== '/network' && path !== '/grants')) {
@@ -874,6 +926,8 @@ const control = makeServer('control', MAX_CONTROL_CONNECTIONS, (request, respons
874
926
  const listen = (server, port, host) => new Promise((resolve) => { server.listen(Number(port), host, resolve); });
875
927
 
876
928
  async function main() {
929
+ // Here and not at the top: requiring this file from a test must not read the runner's argv.
930
+ const bindHost = readBindHost(bindArgument);
877
931
  if (!bindHost || !corridorPort || !windowPort || !controlPort) {
878
932
  console.error('gatekeeper: arguments are <bind host> <corridor port> <window port> <control port>');
879
933
  process.exit(2);
@@ -910,5 +964,5 @@ function describeFailure(error) {
910
964
  if (require.main === module) {
911
965
  main();
912
966
  } else {
913
- module.exports = { chooseAddress, isBlockedAddress, isDomainName, joinSockets, parseIPv4, parseIPv6, refuseTarget, setNetwork, splitTarget, walksOutOfGrant };
967
+ module.exports = { addGrant, chooseAddress, isBlockedAddress, isDomainName, joinSockets, parseIPv4, parseIPv6, refuseTarget, setNetwork, splitTarget, walksOutOfGrant };
914
968
  }
@@ -4,13 +4,14 @@
4
4
  // The corridor refuses every address that leads back to a machine, so a tunnel that really
5
5
  // carries bytes cannot be tested from here. The escape suite does that inside real containers.
6
6
 
7
- import { spawn } from 'node:child_process';
7
+ import { spawn, spawnSync } from 'node:child_process';
8
8
  import crypto from 'node:crypto';
9
9
  import http from 'node:http';
10
10
  import net from 'node:net';
11
11
  import os from 'node:os';
12
- import { readFileSync } from 'node:fs';
12
+ import fs, { readFileSync } from 'node:fs';
13
13
  import { createRequire } from 'node:module';
14
+ import path from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
16
17
  import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@@ -748,6 +749,28 @@ describe('gatekeeper program', () => {
748
749
  expect(answer.body).not.toContain(SECRET);
749
750
  });
750
751
 
752
+ it('adds no header for a grant without a secret, and still takes away what the space sent', async () => {
753
+ expect((await control('POST', '/grants', { id: 'open-registry', upstream: `http://127.0.0.1:${upstreamPort}/npm` })).status).toBe(200);
754
+ const answer = await throughWindow('/model/open-registry/-/package/left-pad', {
755
+ authorization: 'Bearer sk-fake-inside',
756
+ 'x-api-key': 'sk-fake-inside',
757
+ 'x-keep-me': 'yes',
758
+ });
759
+ expect(answer.status).toBe(200);
760
+ const request = seen.at(-1);
761
+ expect(request.url).toBe('/npm/-/package/left-pad');
762
+ expect(request.headers.authorization).toBeUndefined();
763
+ expect(request.headers['x-api-key']).toBeUndefined();
764
+ expect(request.headers['x-keep-me']).toBe('yes');
765
+ expect(Object.keys(request.headers).some((name) => /key|auth|token/i.test(name))).toBe(false);
766
+ });
767
+
768
+ it('takes the grant\'s own header name away too, so a placeholder the space sends never reaches the upstream', async () => {
769
+ expect((await control('POST', '/grants', { id: 'google', upstream: `http://127.0.0.1:${upstreamPort}/v1`, header: 'X-Goog-Api-Key', secret: SECRET })).status).toBe(200);
770
+ await throughWindow('/model/google/models', { 'X-Goog-Api-Key': 'space-window' });
771
+ expect(seen.at(-1).headers['x-goog-api-key']).toBe(SECRET);
772
+ });
773
+
751
774
  it('sends a bearer token for an OpenAI-style grant', async () => {
752
775
  await throughWindow('/model/openai/responses');
753
776
  expect(seen.at(-1).headers.authorization).toBe(`Bearer ${SECRET}`);
@@ -873,7 +896,9 @@ describe('gatekeeper program', () => {
873
896
  ['a numeric last label on the allowlist', '/network', { mode: 'allowlist', domains: ['1.1.1'] }],
874
897
  ['a hex last label on the allowlist', '/network', { mode: 'allowlist', domains: ['a.com.0x2'] }],
875
898
  ['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' }],
899
+ ['a grant with a header and no secret', '/grants', { id: 'x', upstream: 'https://api.example.com', header: 'authorization' }],
900
+ ['a grant with a secret and no header', '/grants', { id: 'x', upstream: 'https://api.example.com', secret: SECRET }],
901
+ ['a grant with an empty secret', '/grants', { id: 'x', upstream: 'https://api.example.com', header: 'authorization', secret: '' }],
877
902
  ['a grant with an id that is not one', '/grants', { id: '../../etc', upstream: 'https://api.example.com', header: 'authorization', secret: SECRET }],
878
903
  ['a grant with a header name that is not one', '/grants', { id: 'x', upstream: 'https://api.example.com', header: 'a: b', secret: SECRET }],
879
904
  ['a grant whose upstream is not a URL', '/grants', { id: 'x', upstream: 'not a url', header: 'authorization', secret: SECRET }],
@@ -883,6 +908,14 @@ describe('gatekeeper program', () => {
883
908
  expect(answer.status).toBe(400);
884
909
  });
885
910
 
911
+ it('says what it holds: the mode, the names and the grant ids, and never a secret', async () => {
912
+ expect((await control('POST', '/network', { mode: 'allowlist', domains: ['Example.com.', 'example.org'] })).status).toBe(200);
913
+ const policy = JSON.parse((await control('GET', '/policy')).body);
914
+ expect(policy).toMatchObject({ mode: 'allowlist', domains: ['example.com', 'example.org'] });
915
+ expect(policy.grants).toEqual(expect.arrayContaining(['anthropic', 'openai']));
916
+ expect(JSON.stringify(policy)).not.toContain(SECRET);
917
+ });
918
+
886
919
  it('answers 404 for anything else and 400 for a body that is not JSON', async () => {
887
920
  expect((await control('GET', '/grants')).status).toBe(404);
888
921
  expect((await control('POST', '/anything')).status).toBe(404);
@@ -958,6 +991,9 @@ describe('gatekeeper program', () => {
958
991
  const after = await journal();
959
992
  expect(after.records).toHaveLength(500);
960
993
  expect(after.dropped).toBeGreaterThan(before.dropped);
994
+ // Since when it records: its own start, as an instant, so the host can say that nothing older exists.
995
+ expect(after.since).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
996
+ expect(after.since).toBe(before.since);
961
997
  // The oldest records went, the newest stayed.
962
998
  expect(after.records.at(-1).host).toBe('blocked.test');
963
999
  }, 60_000);
@@ -1340,6 +1376,85 @@ describe('the rules of the gatekeeper program', () => {
1340
1376
  });
1341
1377
  });
1342
1378
 
1379
+ describe('the gatekeeper program and its bind address', () => {
1380
+ const folders = [];
1381
+ afterAll(() => { for (const folder of folders) fs.rmSync(folder, { recursive: true, force: true }); });
1382
+
1383
+ /** Starts the program with `bind` as its first argument and waits for it to listen or to leave. */
1384
+ const startWith = (bind, ports = ['0', '0', '0']) => new Promise((resolve) => {
1385
+ const child = spawn(process.execPath, [PROGRAM, bind, ...ports.map(String)], { stdio: ['ignore', 'pipe', 'pipe'] });
1386
+ let output = '';
1387
+ const finish = (outcome) => { resolve({ ...outcome, output, stop: () => child.kill() }); };
1388
+ child.stdout.on('data', (chunk) => { output += chunk; if (output.includes('gatekeeper: corridor')) finish({ exited: null }); });
1389
+ child.stderr.on('data', (chunk) => { output += chunk; });
1390
+ child.on('exit', (code) => finish({ exited: code }));
1391
+ });
1392
+
1393
+ /** One TCP connect: `connected` or the error code. */
1394
+ const tryConnect = (host, port) => new Promise((resolve) => {
1395
+ const socket = net.connect({ host, port });
1396
+ socket.setTimeout(3000, () => { socket.destroy(); resolve('timeout'); });
1397
+ socket.on('connect', () => { socket.destroy(); resolve('connected'); });
1398
+ socket.on('error', (error) => resolve(error.code));
1399
+ });
1400
+
1401
+ it('listens on the address the file beside it holds, and on nothing else', async () => {
1402
+ const other = ownAddress();
1403
+ // Without a second address of this machine there is nothing to be refused on.
1404
+ expect(other, 'this machine has no address but loopback').not.toBe('127.0.0.1');
1405
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gatekeeper-bind-'));
1406
+ folders.push(folder);
1407
+ const file = path.join(folder, 'bind');
1408
+ fs.writeFileSync(file, '127.0.0.1\n');
1409
+ const ports = [await freePort(), await freePort(), await freePort()];
1410
+ const started = await startWith(file, ports);
1411
+ try {
1412
+ expect(started.exited).toBeNull();
1413
+ // Positive control: the address in the file answers on both listeners the space uses.
1414
+ expect(await tryConnect('127.0.0.1', ports[0])).toBe('connected');
1415
+ expect(await tryConnect('127.0.0.1', ports[1])).toBe('connected');
1416
+ // Another address of the same machine does not: the program did not listen everywhere.
1417
+ expect(await tryConnect(other, ports[0])).toBe('ECONNREFUSED');
1418
+ expect(await tryConnect(other, ports[1])).toBe('ECONNREFUSED');
1419
+ } finally {
1420
+ started.stop();
1421
+ }
1422
+ });
1423
+
1424
+ it('reads no bind file when it is required rather than run, whatever the runner\'s arguments are', () => {
1425
+ // `node -e` puts its own arguments from argv[1], so the path lands where the program reads its bind argument.
1426
+ // A runner whose first argument is a path would otherwise have its file read, and a
1427
+ // missing one would end the test process.
1428
+ const required = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(PROGRAM)}); console.log('still here')`, 'runner', '/no/such/bind/file'], { encoding: 'utf8' });
1429
+ expect(required.status).toBe(0);
1430
+ expect(required.stdout).toContain('still here');
1431
+ });
1432
+
1433
+ // A gatekeeper made before stage 5b has `0.0.0.0` in its command rather than the file.
1434
+ it.each(['0.0.0.0', '::', 'gatekeeper'])('does not start when it is told to listen on %j directly', async (bind) => {
1435
+ const started = await startWith(bind);
1436
+ expect(started.exited).toBe(2);
1437
+ expect(started.output).not.toContain('gatekeeper: corridor');
1438
+ });
1439
+
1440
+ it.each([
1441
+ ['is missing', null],
1442
+ ['is empty', ''],
1443
+ ['names every interface', '0.0.0.0'],
1444
+ ['names every IPv6 interface', '::'],
1445
+ ['is not an address', 'gatekeeper'],
1446
+ ])('does not start when the bind file %s', async (title, content) => {
1447
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gatekeeper-bind-'));
1448
+ folders.push(folder);
1449
+ const file = path.join(folder, 'bind');
1450
+ if (content !== null) fs.writeFileSync(file, content);
1451
+ const started = await startWith(file);
1452
+ expect(started.exited).toBe(2);
1453
+ expect(started.output).toContain('gatekeeper:');
1454
+ expect(started.output).not.toContain('gatekeeper: corridor');
1455
+ });
1456
+ });
1457
+
1343
1458
  describe('the gatekeeper program as a file', () => {
1344
1459
  const source = readFileSync(PROGRAM, 'utf8');
1345
1460
 
@@ -44,8 +44,19 @@ const SETUP_MEMORY_BYTES = 128 * 1024 * 1024;
44
44
  // Measured: the cache of one fill is 444 MB.
45
45
  const FILLER_MEMORY_BYTES = 2 * 1024 * 1024 * 1024;
46
46
  const FILLER_TMPFS_OPTIONS = 'rw,exec,nosuid,size=1g';
47
- // Variables that would put a secret where `docker inspect` shows it.
48
- const FORBIDDEN_ENVIRONMENT = ['OPENCHAMBER_UI_PASSWORD', 'OPENCODE_AUTH_CONTENT'];
47
+ // The container environment is an allowlist. Anything in it is readable through `docker inspect`
48
+ // and by every process inside, and OpenCode 2 turns some variables into a login: a provider key
49
+ // under one of its catalog's names, or a key inside OPENCODE_CONFIG_CONTENT. So a container may
50
+ // carry the variables we set and the base image's own, and nothing else. The server password is
51
+ // named on its own as well, so its message stays specific.
52
+ const FORBIDDEN_ENVIRONMENT = ['OPENCHAMBER_UI_PASSWORD'];
53
+ // The variables the pinned base image sets itself. Docker copies them into every container made
54
+ // from it. Read on 2026-09-24 with
55
+ // `docker image inspect <SPACE_BASE_IMAGE> --format '{{json .Config.Env}}'`, which answered
56
+ // PATH, NODE_VERSION=22.23.2 and YARN_VERSION=1.22.22. Read them again when the digest changes.
57
+ const BASE_IMAGE_ENVIRONMENT_NAMES = ['PATH', 'NODE_VERSION', 'YARN_VERSION'];
58
+ const SPACE_ENVIRONMENT_NAMES = new Set([...Object.keys(SPACE_ENVIRONMENT), ...BASE_IMAGE_ENVIRONMENT_NAMES]);
59
+ const GATEKEEPER_ENVIRONMENT_NAMES = new Set([...Object.keys(GATEKEEPER_ENVIRONMENT), ...BASE_IMAGE_ENVIRONMENT_NAMES]);
49
60
 
50
61
  // Container output lands in a file on the Docker host. One 10 MB file, no rotation copies.
51
62
  // The `local` driver refuses max-file=1 unless compression is off.
@@ -265,7 +276,7 @@ const mentionsRuntimeSocket = (text) => String(text ?? '').includes('docker.sock
265
276
  * the tmpfs, the network the container must run on, and which mounts it may have, which
266
277
  * `mountViolations(mounts)` answers.
267
278
  */
268
- function findCommonViolations({ container, tmpfsOptions, networkMode, mountViolations }) {
279
+ function findCommonViolations({ container, tmpfsOptions, networkMode, mountViolations, allowedEnvironment }) {
269
280
  const violations = [];
270
281
  const violate = (check, message) => violations.push({ check, message });
271
282
 
@@ -273,8 +284,11 @@ function findCommonViolations({ container, tmpfsOptions, networkMode, mountViola
273
284
  const host = container?.HostConfig ?? {};
274
285
  const mounts = container?.Mounts ?? [];
275
286
 
276
- const secretVariables = (config.Env ?? []).map((entry) => String(entry).split('=')[0]).filter((name) => FORBIDDEN_ENVIRONMENT.includes(name));
287
+ const variableNames = (config.Env ?? []).map((entry) => String(entry).split('=')[0]);
288
+ const secretVariables = variableNames.filter((name) => FORBIDDEN_ENVIRONMENT.includes(name));
277
289
  if (secretVariables.length > 0) violate('environment', `The container environment holds a secret: ${secretVariables.join(', ')}`);
290
+ const unexpectedVariables = variableNames.filter((name) => !FORBIDDEN_ENVIRONMENT.includes(name) && !allowedEnvironment.has(name));
291
+ if (unexpectedVariables.length > 0) violate('environment', `The container environment holds variables nobody set for it: ${unexpectedVariables.join(', ')}`);
278
292
  if (config.User !== SPACE_USER) violate('user', `Runs as '${config.User ?? ''}', expected ${SPACE_USER}`);
279
293
  if (host.ReadonlyRootfs !== true) violate('read_only', 'The root filesystem is writable');
280
294
  if (host.Privileged !== false) violate('privileged', 'The container is privileged');
@@ -374,6 +388,7 @@ export function findHardeningViolations({ spaceId, owner, container, network, to
374
388
  tmpfsOptions: TMPFS_OPTIONS,
375
389
  networkMode: networkName,
376
390
  mountViolations: (mounts) => findSpaceMountViolations({ mounts, prefix, owner, toolsVolume }),
391
+ allowedEnvironment: SPACE_ENVIRONMENT_NAMES,
377
392
  });
378
393
  const violate = (check, message) => violations.push({ check, message });
379
394
 
@@ -427,6 +442,7 @@ export function findGatekeeperHardeningViolations({ spaceId, owner, container, o
427
442
  check: 'mounts',
428
443
  message: `The gatekeeper has ${mounts.length} mount(s) and must have none: ${mounts.map((mount) => mount.Destination).join(', ')}`,
429
444
  }]),
445
+ allowedEnvironment: GATEKEEPER_ENVIRONMENT_NAMES,
430
446
  });
431
447
  const violate = (check, message) => violations.push({ check, message });
432
448