@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.
- package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- 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
|
|
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
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
161
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
608
|
-
*
|
|
609
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
48
|
-
|
|
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
|
|
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
|
|