@vantran-se/openchamber-web 1.24.4 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/bin/lib/commands-connect-url.js +3 -8
- package/bin/lib/commands-tunnel.js +3 -0
- package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-B82Oye3V.js} +1 -1
- package/dist/assets/DiffView-DJLmWn6Z.js +4 -0
- package/dist/assets/FilesView-DjksmZRo.js +17 -0
- package/dist/assets/{GitView-Byu2DY7s.js → GitView-DiUsUIgr.js} +4 -4
- package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-CYwWYLr3.js} +1 -1
- package/dist/assets/GuestSurfacePane-CmctyRI4.js +1 -0
- package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-DOHHcbgJ.js} +1 -1
- package/dist/assets/LinearIssuesView-DaqnylcV.js +1 -0
- package/dist/assets/MarkdownImageGallery-HJNvWt7L.js +1 -0
- package/dist/assets/MarkdownRendererImpl-nDZRGg2I.js +12 -0
- package/dist/assets/OnboardingScreen-C2B5Bsof.js +1 -0
- package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-C4FLBjpt.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-C_1S9c9n.js} +1 -1
- package/dist/assets/PlanView-_eZvb-G5.js +5 -0
- package/dist/assets/PluginPane-sM8fcZIV.js +3 -0
- package/dist/assets/SettingsView-BRfr46hb.js +29 -0
- package/dist/assets/SettingsWindow-CcUDwQHg.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-CrN1ZGwl.js +1 -0
- package/dist/assets/ToolOutputDialog-Cij4jI09.js +15 -0
- package/dist/assets/ToolPartDiffPreview-BXzwQ-JY.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-DSWTLJNH.js} +4 -4
- package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-CpUUgY8H.js} +2 -2
- package/dist/assets/appThemeRegistry-B5tHTkR8.js +1 -0
- package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-D7D8qV8p.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-cq9uQ30i.js} +1 -1
- package/dist/assets/de-CUO1-KqF.js +10 -0
- package/dist/assets/es-BlbC6sGJ.js +16 -0
- package/dist/assets/fr-D2nwe_0W.js +14 -0
- package/dist/assets/index-BQOoYIOu.js +1 -0
- package/dist/assets/index-CLcM8HKx.css +1 -0
- package/dist/assets/index-CvLgnuNI.js +41 -0
- package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D0obJ2p8.js} +1 -1
- package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-DB3_rjyW.js} +1 -1
- package/dist/assets/ja-CA7vnDsZ.js +16 -0
- package/dist/assets/ko-Chy6a9l-.js +16 -0
- package/dist/assets/main-B9JFK3vF.js +896 -0
- package/dist/assets/main-Bhiqnrhd.js +2 -0
- package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-tfqOFaBd.js} +4 -4
- package/dist/assets/miniChat-B0UOL5zA.js +2 -0
- package/dist/assets/mobile-BJppFIEo.js +2 -0
- package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-Db6KuwCP.js} +3 -3
- package/dist/assets/pl-CTR5A_1P.js +16 -0
- package/dist/assets/pt-BR-BE1O1KEx.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-Yz5sfoFh.js +2 -0
- package/dist/assets/renderMobileApp-QQ-yB8aY.js +25 -0
- package/dist/assets/runtimeEndpointReset-DKDdzRzm.js +27 -0
- package/dist/assets/scheduledTasksApi-C5sCuNqf.js +1 -0
- package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-xURoo5t_.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-BZLGuYTU.js} +1 -1
- package/dist/assets/tr-Bh5vK6cW.js +7 -0
- package/dist/assets/uk-CLDkZFy_.js +16 -0
- package/dist/assets/useAppFontEffects-BxrtCnfR.js +7337 -0
- package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CeXuR-SR.js} +2 -2
- package/dist/assets/useRootScrollLock-DgWxezc1.js +2 -0
- package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-DOuaQUZ0.js} +2 -2
- package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
- package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
- package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
- package/dist/assets/{web-components-DxX8bZ1o.js → web-components-63H1h21j.js} +2 -2
- package/dist/assets/zh-CN-DODgcmYj.js +16 -0
- package/dist/assets/zh-TW-ByNfIwLA.js +16 -0
- package/dist/index.html +13 -7
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +6 -5
- package/server/index.d.ts +1 -0
- package/server/index.js +246 -91
- package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
- package/server/lib/agent-tool/runtime.js +135 -54
- package/server/lib/agent-tool/runtime.test.js +155 -87
- package/server/lib/browser-control/DOCUMENTATION.md +37 -2
- package/server/lib/browser-control/provider.js +217 -0
- package/server/lib/browser-control/provider.test.js +251 -0
- package/server/lib/context-obligatory/runtime.js +61 -50
- package/server/lib/context-obligatory/runtime.test.js +62 -162
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +68 -57
- package/server/lib/event-stream/directory-ws-bridge.js +1 -1
- package/server/lib/event-stream/global-hub.js +10 -1
- package/server/lib/event-stream/global-hub.test.js +27 -26
- package/server/lib/event-stream/index.js +6 -0
- package/server/lib/event-stream/protocol.js +26 -19
- package/server/lib/event-stream/protocol.test.js +18 -7
- package/server/lib/event-stream/rebind.test.js +5 -5
- package/server/lib/event-stream/runtime.test.js +17 -15
- package/server/lib/event-stream/translate-v2.js +313 -0
- package/server/lib/event-stream/translate-v2.test.js +209 -0
- package/server/lib/event-stream/upstream-reader.test.js +16 -16
- package/server/lib/fs/DOCUMENTATION.md +4 -2
- package/server/lib/fs/byte-range.js +35 -0
- package/server/lib/fs/byte-range.test.js +31 -0
- package/server/lib/fs/routes.js +47 -42
- package/server/lib/fs/routes.test.js +123 -18
- package/server/lib/git/service.js +12 -0
- package/server/lib/github/DOCUMENTATION.md +1 -1
- package/server/lib/github/pr-status.js +22 -2
- package/server/lib/github/pr-status.test.js +27 -14
- package/server/lib/guests/DOCUMENTATION.md +13 -0
- package/server/lib/guests/catalog.js +12 -0
- package/server/lib/guests/catalog.test.js +33 -0
- package/server/lib/guests/routes.js +10 -1
- package/server/lib/guests/service.js +192 -14
- package/server/lib/guests/service.test.js +201 -1
- package/server/lib/guests/surface.js +630 -0
- package/server/lib/guests/surface.test.js +486 -0
- package/server/lib/markdown-image-grants/routes.js +18 -5
- package/server/lib/markdown-image-grants/routes.test.js +9 -6
- package/server/lib/message-queue/DOCUMENTATION.md +33 -22
- package/server/lib/message-queue/runtime.js +155 -100
- package/server/lib/message-queue/runtime.test.js +168 -82
- package/server/lib/notifications/DOCUMENTATION.md +28 -0
- package/server/lib/notifications/control-stream.http.test.js +68 -0
- package/server/lib/notifications/emitter-runtime.js +15 -0
- package/server/lib/notifications/emitter-runtime.test.js +32 -0
- package/server/lib/notifications/push-runtime.js +5 -1
- package/server/lib/notifications/push-runtime.test.js +2 -2
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +115 -26
- package/server/lib/notifications/template-runtime.js +13 -15
- package/server/lib/notifications/template-runtime.test.js +16 -9
- package/server/lib/notifications/trigger-runtime.test.js +187 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
- package/server/lib/openchamber-control/actions.js +3 -2
- package/server/lib/openchamber-control/error.js +1 -1
- package/server/lib/openchamber-control/file-open.js +59 -0
- package/server/lib/openchamber-control/file-open.test.js +57 -0
- package/server/lib/openchamber-control/routes.test.js +12 -0
- package/server/lib/openchamber-control/service.js +102 -49
- package/server/lib/openchamber-control/service.test.js +84 -44
- package/server/lib/openchamber-sessions/archive-store.js +224 -0
- package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
- package/server/lib/openchamber-sessions/opencode-client.js +22 -0
- package/server/lib/openchamber-sessions/routes.js +398 -328
- package/server/lib/openchamber-sessions/routes.test.js +777 -740
- package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
- package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
- package/server/lib/opencode/DOCUMENTATION.md +449 -29
- package/server/lib/opencode/agents.js +233 -327
- package/server/lib/opencode/auth.js +41 -44
- package/server/lib/opencode/auth.test.js +92 -0
- package/server/lib/opencode/cli-upgrade.d.ts +4 -0
- package/server/lib/opencode/cli-upgrade.js +18 -0
- package/server/lib/opencode/cli-upgrade.test.js +36 -0
- package/server/lib/opencode/commands.js +164 -145
- package/server/lib/opencode/compatibility.d.ts +16 -0
- package/server/lib/opencode/compatibility.js +88 -0
- package/server/lib/opencode/compatibility.test.js +62 -0
- package/server/lib/opencode/config-entity-routes.js +58 -29
- package/server/lib/opencode/config-entity-v2.test.js +290 -0
- package/server/lib/opencode/config-mutation-response.js +17 -7
- package/server/lib/opencode/config-mutation-response.test.js +4 -7
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.d.ts +183 -0
- package/server/lib/opencode/config-v2.js +801 -0
- package/server/lib/opencode/config-v2.test.js +466 -0
- package/server/lib/opencode/credential-db.js +144 -0
- package/server/lib/opencode/credential-db.test.js +104 -0
- package/server/lib/opencode/env-runtime.js +48 -9
- package/server/lib/opencode/env-runtime.test.js +45 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -5
- package/server/lib/opencode/lifecycle-process.test.js +7 -4
- package/server/lib/opencode/lifecycle.js +85 -30
- package/server/lib/opencode/lifecycle.test.js +80 -8
- package/server/lib/opencode/login-shell-env.js +27 -0
- package/server/lib/opencode/managed-config-file.js +119 -0
- package/server/lib/opencode/managed-config-file.test.js +152 -0
- package/server/lib/opencode/managed-plugin-config.js +33 -13
- package/server/lib/opencode/mcp.js +43 -149
- package/server/lib/opencode/network-runtime.js +6 -7
- package/server/lib/opencode/openchamber-routes.test.js +2 -5
- package/server/lib/opencode/plugin-routes.js +3 -3
- package/server/lib/opencode/plugin-routes.test.js +6 -24
- package/server/lib/opencode/plugins.js +91 -40
- package/server/lib/opencode/plugins.test.js +28 -15
- package/server/lib/opencode/providers.js +63 -83
- package/server/lib/opencode/providers.test.js +128 -59
- package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
- package/server/lib/opencode/proxy.js +179 -129
- package/server/lib/opencode/pwa-manifest-routes.js +4 -2
- package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
- package/server/lib/opencode/response-envelope.js +14 -0
- package/server/lib/opencode/response-envelope.test.js +23 -0
- package/server/lib/opencode/routes-upgrade.test.js +73 -116
- package/server/lib/opencode/routes.js +121 -428
- package/server/lib/opencode/server-utils-runtime.js +10 -4
- package/server/lib/opencode/session-activity.js +78 -0
- package/server/lib/opencode/session-runtime.js +21 -10
- package/server/lib/opencode/session-runtime.test.js +9 -9
- package/server/lib/opencode/settings-helpers.js +9 -9
- package/server/lib/opencode/settings-helpers.test.js +7 -12
- package/server/lib/opencode/settings-registry.json +6 -8
- package/server/lib/opencode/settings-runtime.js +18 -1
- package/server/lib/opencode/settings-runtime.test.js +31 -2
- package/server/lib/opencode/shared.js +54 -22
- package/server/lib/opencode/shared.test.js +8 -4
- package/server/lib/opencode/shutdown-runtime.js +54 -7
- package/server/lib/opencode/shutdown-runtime.test.js +224 -30
- package/server/lib/opencode/skill-routes.js +23 -24
- package/server/lib/opencode/skills.js +7 -5
- package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
- package/server/lib/opencode/upgrade-capability.test.js +1 -1
- package/server/lib/opencode/v1-migration-topup.js +288 -0
- package/server/lib/opencode/v1-migration-topup.test.js +266 -0
- package/server/lib/opencode/v2-install.d.ts +8 -0
- package/server/lib/opencode/v2-install.js +158 -0
- package/server/lib/opencode/v2-install.test.js +124 -0
- package/server/lib/opencode/watcher.js +10 -3
- package/server/lib/opencode/watcher.test.js +17 -21
- package/server/lib/opencode/websearch-config.js +66 -0
- package/server/lib/package-manager.js +55 -68
- package/server/lib/package-manager.test.js +241 -75
- package/server/lib/permission-auto-accept/runtime.js +38 -22
- package/server/lib/permission-auto-accept/runtime.test.js +39 -17
- package/server/lib/quota/DOCUMENTATION.md +4 -0
- package/server/lib/quota/providers/xai.js +4 -4
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +7 -0
- package/server/lib/routing/DOCUMENTATION.md +52 -21
- package/server/lib/routing/defaults.js +23 -6
- package/server/lib/routing/jev.js +17 -7
- package/server/lib/routing/routes.http.test.js +68 -33
- package/server/lib/routing/routes.js +65 -22
- package/server/lib/routing/runtime.js +145 -48
- package/server/lib/routing/runtime.test.js +79 -55
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
- package/server/lib/scheduled-tasks/runtime.js +81 -74
- package/server/lib/scheduled-tasks/runtime.test.js +53 -1
- package/server/lib/session-assist/DOCUMENTATION.md +8 -3
- package/server/lib/session-assist/context.js +122 -46
- package/server/lib/session-assist/context.test.js +117 -49
- package/server/lib/session-assist/runtime.js +52 -22
- package/server/lib/session-assist/runtime.test.js +145 -173
- package/server/lib/session-goal/DOCUMENTATION.md +23 -15
- package/server/lib/session-goal/create.js +9 -12
- package/server/lib/session-goal/create.test.js +27 -28
- package/server/lib/session-goal/runtime.js +164 -77
- package/server/lib/session-goal/runtime.test.js +271 -519
- package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
- package/server/lib/session-knowledge/runtime.js +59 -41
- package/server/lib/session-knowledge/runtime.test.js +90 -20
- package/server/lib/small-model/DOCUMENTATION.md +154 -208
- package/server/lib/small-model/client.js +113 -0
- package/server/lib/small-model/index.js +296 -158
- package/server/lib/small-model/index.test.js +423 -256
- package/server/lib/small-model/routes.js +1 -1
- package/server/lib/spaces/DOCUMENTATION.md +807 -0
- package/server/lib/spaces/code-in-bait.js +185 -0
- package/server/lib/spaces/code-in.js +641 -0
- package/server/lib/spaces/code-in.test.js +1048 -0
- package/server/lib/spaces/errors.js +8 -0
- package/server/lib/spaces/exec-http.js +94 -0
- package/server/lib/spaces/gatekeeper-channel.js +202 -0
- package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
- package/server/lib/spaces/gatekeeper-program.cjs +914 -0
- package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
- package/server/lib/spaces/hardening.js +457 -0
- package/server/lib/spaces/hardening.test.js +368 -0
- package/server/lib/spaces/host-git.js +127 -0
- package/server/lib/spaces/host-git.test.js +137 -0
- package/server/lib/spaces/labels.js +234 -0
- package/server/lib/spaces/labels.test.js +219 -0
- package/server/lib/spaces/layout.js +184 -0
- package/server/lib/spaces/layout.test.js +30 -0
- package/server/lib/spaces/manager.js +82 -0
- package/server/lib/spaces/manager.test.js +114 -0
- package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
- package/server/lib/spaces/places/contract-suite.js +156 -0
- package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
- package/server/lib/spaces/places/contract.test.js +6 -0
- package/server/lib/spaces/places/docker-engine.js +126 -0
- package/server/lib/spaces/places/docker-live-support.js +202 -0
- package/server/lib/spaces/places/docker-tools.js +186 -0
- package/server/lib/spaces/places/docker.js +587 -0
- package/server/lib/spaces/places/docker.test.js +1610 -0
- package/server/lib/spaces/places/escape-suite.js +1018 -0
- package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
- package/server/lib/spaces/places/fake-docker.js +375 -0
- package/server/lib/spaces/places/memory-place.js +103 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
- package/server/lib/spaces/places/registry.js +43 -0
- package/server/lib/spaces/places/registry.test.js +50 -0
- package/server/lib/spaces/places/server.docker.live.test.js +225 -0
- package/server/lib/spaces/run-command.js +261 -0
- package/server/lib/spaces/run-command.test.js +396 -0
- package/server/lib/spaces/space-server.js +190 -0
- package/server/lib/spaces/space-server.test.js +335 -0
- package/server/lib/spaces/tools-filler.js +82 -0
- package/server/lib/spaces/tools-filler.test.js +148 -0
- package/server/lib/spaces/tools-pack.js +49 -0
- package/server/lib/spaces/tools-pack.test.js +57 -0
- package/server/lib/spaces/tools.js +138 -0
- package/server/lib/spaces/tools.test.js +153 -0
- package/server/lib/tts/openai-sdk.js +11 -0
- package/server/lib/tts/service.js +7 -5
- package/server/lib/tts/stt.js +3 -2
- package/server/lib/tunnels/DOCUMENTATION.md +2 -0
- package/server/lib/tunnels/routes.js +7 -0
- package/server/lib/tunnels/routes.test.js +55 -0
- package/server/lib/ui-auth/ui-auth.js +38 -2
- package/server/lib/ui-auth/ui-auth.test.js +42 -0
- package/server/lib/ui-auth/ui-passkeys.js +12 -10
- package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
- package/server/lib/walkthrough/pull-request.js +61 -8
- package/server/lib/walkthrough/pull-request.test.js +54 -1
- package/server/lib/walkthrough/routes.js +20 -0
- package/server/lib/walkthrough/routes.test.js +19 -0
- package/server/opencode-proxy.test.js +190 -166
- package/server/opencode-worktree-readiness.test.js +5 -4
- package/dist/assets/DiffView-B2CD9wC8.js +0 -4
- package/dist/assets/FilesView-C4vMFclc.js +0 -13
- package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
- package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
- package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
- package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
- package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
- package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
- package/dist/assets/SettingsView-CSinuH-1.js +0 -29
- package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
- package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
- package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
- package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
- package/dist/assets/de-Bro6_zyl.js +0 -10
- package/dist/assets/es-Bb9XQZvd.js +0 -16
- package/dist/assets/fr-Dgfzn-IL.js +0 -14
- package/dist/assets/index-CtQNmqUL.js +0 -1
- package/dist/assets/index-DqaMWNrC.css +0 -1
- package/dist/assets/index-Dqc6USl3.js +0 -40
- package/dist/assets/ja-BYsakGFh.js +0 -16
- package/dist/assets/ko-BSaQz9aN.js +0 -16
- package/dist/assets/main-B62_pjL5.js +0 -2
- package/dist/assets/main-BEdcOI2P.js +0 -896
- package/dist/assets/miniChat-LT8Rqofr.js +0 -2
- package/dist/assets/mobile-CqarEynh.js +0 -2
- package/dist/assets/pl-OIviPdoj.js +0 -16
- package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
- package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
- package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
- package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
- package/dist/assets/tr-CQI-KPre.js +0 -7
- package/dist/assets/uk-CqW7R4DA.js +0 -16
- package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
- package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
- package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
- package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
- package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
- package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
- package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
- package/server/lib/routing/feature-flag.js +0 -11
- package/server/lib/small-model/call.js +0 -851
- package/server/lib/small-model/call.test.js +0 -1292
- package/server/lib/small-model/catalog.js +0 -13
- package/server/lib/small-model/resolve.js +0 -131
- package/server/lib/small-model/resolve.test.js +0 -197
- package/server/lib/small-model/runtime-providers.js +0 -164
- package/server/lib/small-model/runtime-providers.test.js +0 -118
- package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
- package/server/lib/system-prompt/runtime.js +0 -49
- package/server/lib/system-prompt/runtime.test.js +0 -76
- package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// One HTTP request inside a container, over `exec` and nothing else, so that it works for every
|
|
2
|
+
// place. The two channels written on top of this, `space-server.js` and `gatekeeper-channel.js`,
|
|
3
|
+
// share these pieces: the curl argv, the request as a curl config, and the answer parser.
|
|
4
|
+
//
|
|
5
|
+
// Everything that comes back is untrusted. What listens on the port inside can be agent code.
|
|
6
|
+
|
|
7
|
+
import { SpaceError } from './errors.js';
|
|
8
|
+
import { IMAGE_CURL } from './layout.js';
|
|
9
|
+
|
|
10
|
+
// How much text from inside a container an error message may carry.
|
|
11
|
+
const ERROR_TAIL_CHARACTERS = 2_000;
|
|
12
|
+
|
|
13
|
+
// How much of an answer's header block the host is willing to parse. A real answer is about 1 KB.
|
|
14
|
+
const MAX_HEADER_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_HEADER_LINES = 200;
|
|
16
|
+
|
|
17
|
+
// Exit codes of a `docker exec` that failed by itself: no such container, container not running,
|
|
18
|
+
// curl missing. curl uses 1 too. Measured with curl 7.88.1: a listener that answers `hello` gives
|
|
19
|
+
// exit code 1 and `curl: (1) Received HTTP/0.9 when not allowed`. curl starts every error line
|
|
20
|
+
// with `curl:`, so one of these codes means "exec failed" only when stderr does not start like that.
|
|
21
|
+
export const EXEC_FAILED_CODES = [1, 125, 126, 127];
|
|
22
|
+
export const isFromCurl = (stderr) => String(stderr ?? '').trimStart().startsWith('curl:');
|
|
23
|
+
|
|
24
|
+
// The CLI of an exec was killed. For a request that says something about the inside, not about a Docker step.
|
|
25
|
+
export const EXEC_INTERRUPTED_CODES = ['command_timeout', 'command_killed'];
|
|
26
|
+
|
|
27
|
+
export const tail = (text) => String(text ?? '').trim().slice(-ERROR_TAIL_CHARACTERS);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The curl arguments. `--disable` must stay first: it is what keeps curl away from the
|
|
31
|
+
* `~/.curlrc` of the user inside, which the agent can write. `--noproxy '*'` must stay too:
|
|
32
|
+
* a space has proxy variables in its environment, and measured with curl 7.88.1, curl sends
|
|
33
|
+
* even a request to 127.0.0.1 to that proxy without it.
|
|
34
|
+
*/
|
|
35
|
+
export const buildCurlArgs = (timeoutSeconds) => [
|
|
36
|
+
IMAGE_CURL, '--disable', '--noproxy', '*', '--silent', '--show-error', '--include',
|
|
37
|
+
'--max-time', String(timeoutSeconds), '--config', '-',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// A quoted value in a curl config file. Backslash escapes are the only special syntax inside the quotes.
|
|
41
|
+
const quoteConfigValue = (value) => {
|
|
42
|
+
const text = String(value);
|
|
43
|
+
if (text.includes('\0')) {
|
|
44
|
+
throw new SpaceError('invalid_request', 'A request to a container cannot hold a NUL character');
|
|
45
|
+
}
|
|
46
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The whole request as a curl config for stdin. A cookie, a token or a grant's secret in it
|
|
51
|
+
* therefore shows up in no argument list, on the host or inside the container.
|
|
52
|
+
*/
|
|
53
|
+
export const buildCurlConfig = ({ url, method, headers = {}, body = null }) => [
|
|
54
|
+
`url = ${quoteConfigValue(url)}`,
|
|
55
|
+
`request = ${quoteConfigValue(method)}`,
|
|
56
|
+
// Without this curl asks "Expect: 100-continue" for a large body and the answer gets two header blocks.
|
|
57
|
+
'header = "Expect:"',
|
|
58
|
+
...Object.entries(headers).map(([name, value]) => `header = ${quoteConfigValue(`${name}: ${value}`)}`),
|
|
59
|
+
// `data-raw`, because `data-binary` reads a body that starts with `@` as a file name.
|
|
60
|
+
...(body === null ? [] : [`data-raw = ${quoteConfigValue(body)}`]),
|
|
61
|
+
].join('\n');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The answer of whatever listens on the port. `unreadable(why)` makes the error of the calling
|
|
65
|
+
* channel, so each one keeps its own code and its own words.
|
|
66
|
+
*
|
|
67
|
+
* Header names go into an object without a prototype, so `__proto__` or `constructor` is one more name.
|
|
68
|
+
*/
|
|
69
|
+
export function parseResponse(output, unreadable) {
|
|
70
|
+
const headerEnd = output.indexOf('\r\n\r\n');
|
|
71
|
+
// The cap comes before any work on the header block, so a huge block costs the host nothing.
|
|
72
|
+
if (headerEnd > MAX_HEADER_BYTES) {
|
|
73
|
+
throw unreadable(`answered with more than ${MAX_HEADER_BYTES} bytes of headers`);
|
|
74
|
+
}
|
|
75
|
+
const [statusLine, ...headerLines] = output.slice(0, Math.max(headerEnd, 0)).split('\r\n');
|
|
76
|
+
const status = Number.parseInt(/^HTTP\/[\d.]+ (\d{3})/.exec(statusLine)?.[1] ?? '', 10);
|
|
77
|
+
if (headerEnd < 0 || Number.isNaN(status)) {
|
|
78
|
+
throw unreadable('answered with something that is not HTTP');
|
|
79
|
+
}
|
|
80
|
+
if (headerLines.length > MAX_HEADER_LINES) {
|
|
81
|
+
throw unreadable(`answered with more than ${MAX_HEADER_LINES} header lines`);
|
|
82
|
+
}
|
|
83
|
+
const headers = Object.create(null);
|
|
84
|
+
for (const line of headerLines) {
|
|
85
|
+
const colon = line.indexOf(':');
|
|
86
|
+
if (colon > 0) {
|
|
87
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
88
|
+
// Pushed, not copied: a copy per line is quadratic in the lines that the inside chooses to send.
|
|
89
|
+
headers[name] ??= [];
|
|
90
|
+
headers[name].push(line.slice(colon + 1).trim());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { status, headers, body: output.slice(headerEnd + 4) };
|
|
94
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The host's channel to the gatekeeper of a space, written once on top of `exec` for every place.
|
|
2
|
+
// The space cannot see it: the control listener binds the gatekeeper's own loopback, and the
|
|
3
|
+
// requests run as `curl` inside the gatekeeper container.
|
|
4
|
+
//
|
|
5
|
+
// Every secret the host delivers, a grant's key above all, travels on the stdin of an `exec` as a
|
|
6
|
+
// curl config. It is in no argument list, on the host or inside, in no label and in no file.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { SpaceError } from './errors.js';
|
|
11
|
+
import {
|
|
12
|
+
EXEC_FAILED_CODES,
|
|
13
|
+
EXEC_INTERRUPTED_CODES,
|
|
14
|
+
buildCurlArgs,
|
|
15
|
+
buildCurlConfig,
|
|
16
|
+
isFromCurl,
|
|
17
|
+
parseResponse,
|
|
18
|
+
tail,
|
|
19
|
+
} from './exec-http.js';
|
|
20
|
+
import { ROLE_GATEKEEPER } from './labels.js';
|
|
21
|
+
import {
|
|
22
|
+
GATEKEEPER_CONTROL_HOST,
|
|
23
|
+
GATEKEEPER_CONTROL_PORT,
|
|
24
|
+
GATEKEEPER_PROGRAM_DIRECTORY,
|
|
25
|
+
GATEKEEPER_PROGRAM_PATH,
|
|
26
|
+
IMAGE_ONLY_PATH,
|
|
27
|
+
IMAGE_SH,
|
|
28
|
+
} from './layout.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The gatekeeper's program, read from the file beside this one. `packages/web` ships `server/`
|
|
32
|
+
* as plain files, so this works in the published package as it does in a checkout.
|
|
33
|
+
*/
|
|
34
|
+
export const GATEKEEPER_PROGRAM = readFileSync(new URL('./gatekeeper-program.cjs', import.meta.url), 'utf8');
|
|
35
|
+
|
|
36
|
+
const REQUEST_SECONDS = 20;
|
|
37
|
+
// The gatekeeper answers in milliseconds. A long attempt would only delay the wall-clock deadline.
|
|
38
|
+
const HEALTH_SECONDS = 3;
|
|
39
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
40
|
+
const READY_PAUSE_MS = 250;
|
|
41
|
+
// A second stop for a caller whose clock stands still. The deadline is the real limit.
|
|
42
|
+
const READY_MAX_ATTEMPTS = 1_000;
|
|
43
|
+
|
|
44
|
+
// A journal record is five small fields. These caps are what the host is willing to read back.
|
|
45
|
+
const MAX_JOURNAL_RECORDS = 1_000;
|
|
46
|
+
const MAX_JOURNAL_BODY_CHARACTERS = 1024 * 1024;
|
|
47
|
+
const MAX_JOURNAL_FIELD_CHARACTERS = 256;
|
|
48
|
+
|
|
49
|
+
// The program goes through a temporary name, so the waiting container never runs half a file.
|
|
50
|
+
const WRITE_PROGRAM_SCRIPT = [
|
|
51
|
+
IMAGE_ONLY_PATH,
|
|
52
|
+
`mkdir -p ${GATEKEEPER_PROGRAM_DIRECTORY}`,
|
|
53
|
+
`&& cat > ${GATEKEEPER_PROGRAM_PATH}.new && mv ${GATEKEEPER_PROGRAM_PATH}.new ${GATEKEEPER_PROGRAM_PATH}`,
|
|
54
|
+
].join(' ');
|
|
55
|
+
|
|
56
|
+
const pause = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
|
|
57
|
+
|
|
58
|
+
const unreadableAnswer = (why) => new SpaceError('gatekeeper_answer_unreadable', `The gatekeeper of the space ${why}`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One field of a journal record, as text and no longer than the host reads. A list or an object
|
|
62
|
+
* where a field should be comes out empty, because a record's fields are text and numbers.
|
|
63
|
+
*/
|
|
64
|
+
const text = (value) => (value === null || value === undefined || value instanceof Object ? '' : String(value).slice(0, MAX_JOURNAL_FIELD_CHARACTERS));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `exec` is the place operation: `(spaceId, argv, { stdin, timeoutMs, target })`. `target` names
|
|
68
|
+
* the container, and this channel always asks for the gatekeeper.
|
|
69
|
+
*
|
|
70
|
+
* Everything that comes back is untrusted, exactly as with the server inside a space. No answer
|
|
71
|
+
* makes this module throw anything but a SpaceError.
|
|
72
|
+
*/
|
|
73
|
+
export function createGatekeeperChannel({ exec, wait = pause, now = Date.now }) {
|
|
74
|
+
const execInGatekeeper = (spaceId, argv, options) => exec(spaceId, argv, { ...options, target: ROLE_GATEKEEPER });
|
|
75
|
+
|
|
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 });
|
|
79
|
+
if (result.code !== 0) {
|
|
80
|
+
throw new SpaceError('gatekeeper_setup_failed', `Could not store the gatekeeper's program: ${tail(result.stderr) || `exit code ${result.code}`}`);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** One HTTP request to the control listener. Resolves `{ status, headers, body }` for any status. */
|
|
85
|
+
const request = async (spaceId, { method = 'GET', path, body = null, timeoutSeconds = REQUEST_SECONDS }) => {
|
|
86
|
+
const config = buildCurlConfig({
|
|
87
|
+
url: `http://${GATEKEEPER_CONTROL_HOST}:${GATEKEEPER_CONTROL_PORT}${path}`,
|
|
88
|
+
method,
|
|
89
|
+
headers: body === null ? {} : { 'Content-Type': 'application/json' },
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await execInGatekeeper(spaceId, buildCurlArgs(timeoutSeconds), { stdin: `${config}\n`, timeoutMs: (timeoutSeconds + 10) * 1000 });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Too much output is an answer from inside, not an interrupted Docker step.
|
|
97
|
+
if (error.code === 'command_output_too_large') {
|
|
98
|
+
throw unreadableAnswer('answered with more data than the host accepts');
|
|
99
|
+
}
|
|
100
|
+
// No Docker step was interrupted here, so this must not look like one to a rollback.
|
|
101
|
+
if (EXEC_INTERRUPTED_CODES.includes(error.code)) {
|
|
102
|
+
throw new SpaceError('gatekeeper_unreachable', `The request to the gatekeeper did not finish in time: ${error.message}`, { curlExitCode: null, execFailed: false });
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (result.code !== 0) {
|
|
107
|
+
throw new SpaceError(
|
|
108
|
+
'gatekeeper_unreachable',
|
|
109
|
+
`The gatekeeper of the space did not answer: ${tail(result.stderr) || `curl exit code ${result.code}`}`,
|
|
110
|
+
{ curlExitCode: result.code, execFailed: EXEC_FAILED_CODES.includes(result.code) && !isFromCurl(result.stderr) },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return parseResponse(result.stdout, unreadableAnswer);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** A control request that must succeed. Anything but 200 is a refusal the caller must see. */
|
|
117
|
+
const command = async (spaceId, path, body, what) => {
|
|
118
|
+
const answer = await request(spaceId, { method: 'POST', path, body: JSON.stringify(body) });
|
|
119
|
+
if (answer.status !== 200) {
|
|
120
|
+
throw new SpaceError('gatekeeper_refused', `The gatekeeper refused to ${what}: ${tail(answer.body) || `status ${answer.status}`}`, { status: answer.status });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolves when the control listener answers, which it does only after the corridor and the
|
|
126
|
+
* window accept connections. The wait ends at a wall-clock deadline whatever happens inside.
|
|
127
|
+
*/
|
|
128
|
+
const waitUntilReady = async (spaceId) => {
|
|
129
|
+
const started = now();
|
|
130
|
+
let last = 'no answer yet';
|
|
131
|
+
for (let attempt = 0; attempt < READY_MAX_ATTEMPTS && now() - started < READY_TIMEOUT_MS; attempt += 1) {
|
|
132
|
+
try {
|
|
133
|
+
const answer = await request(spaceId, { path: '/health', timeoutSeconds: HEALTH_SECONDS });
|
|
134
|
+
if (answer.status === 200) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
last = `the control channel answered ${answer.status}`;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const fromInside = error.code === 'gatekeeper_answer_unreadable'
|
|
140
|
+
|| (error.code === 'gatekeeper_unreachable' && error.details?.execFailed !== true);
|
|
141
|
+
if (!fromInside) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
last = error.message;
|
|
145
|
+
}
|
|
146
|
+
await wait(READY_PAUSE_MS);
|
|
147
|
+
}
|
|
148
|
+
throw new SpaceError(
|
|
149
|
+
'gatekeeper_not_ready',
|
|
150
|
+
`The gatekeeper of the space did not become ready within ${Math.round((now() - started) / 1000)} seconds (${last}). Look at the output of its container.`,
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The network mode and the allowlist, live. A gatekeeper that was just started allows nothing
|
|
156
|
+
* until this call, so a space never reaches further than the host last said it may.
|
|
157
|
+
*/
|
|
158
|
+
const setNetwork = (spaceId, { mode, domains = [] }) => command(spaceId, '/network', { mode, domains }, 'change the network of this space');
|
|
159
|
+
|
|
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}'`);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What the gatekeeper allowed and refused. The answer comes out of a container, so it is read
|
|
165
|
+
* as data: known fields only, capped in count and in length, and never trusted to be complete.
|
|
166
|
+
* `dropped` says how many records the ring buffer has thrown away.
|
|
167
|
+
*/
|
|
168
|
+
const readJournal = async (spaceId) => {
|
|
169
|
+
const answer = await request(spaceId, { path: '/journal' });
|
|
170
|
+
if (answer.status !== 200) {
|
|
171
|
+
throw unreadableAnswer(`answered ${answer.status} for its journal`);
|
|
172
|
+
}
|
|
173
|
+
if (answer.body.length > MAX_JOURNAL_BODY_CHARACTERS) {
|
|
174
|
+
throw unreadableAnswer(`answered with a journal of ${answer.body.length} characters, more than the ${MAX_JOURNAL_BODY_CHARACTERS} the host reads`);
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(answer.body);
|
|
179
|
+
} catch {
|
|
180
|
+
throw unreadableAnswer('answered with a journal that is not JSON');
|
|
181
|
+
}
|
|
182
|
+
if (!parsed || !Array.isArray(parsed.records)) {
|
|
183
|
+
throw unreadableAnswer('answered with a journal that holds no records');
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
records: parsed.records.slice(0, MAX_JOURNAL_RECORDS).map((entry) => {
|
|
187
|
+
// Only an object has our fields. A string or a list would answer `at` with a method of its own.
|
|
188
|
+
const record = entry instanceof Object && !Array.isArray(entry) ? entry : {};
|
|
189
|
+
return {
|
|
190
|
+
at: text(record.at),
|
|
191
|
+
listener: text(record.listener),
|
|
192
|
+
host: text(record.host),
|
|
193
|
+
port: Number.isSafeInteger(record.port) ? record.port : 0,
|
|
194
|
+
decision: text(record.decision),
|
|
195
|
+
};
|
|
196
|
+
}),
|
|
197
|
+
dropped: Number.isSafeInteger(parsed.dropped) && parsed.dropped >= 0 ? parsed.dropped : 0,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return { writeProgram, waitUntilReady, setNetwork, addGrant, readJournal };
|
|
202
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { SpaceError } from './errors.js';
|
|
4
|
+
import { GATEKEEPER_PROGRAM, createGatekeeperChannel } from './gatekeeper-channel.js';
|
|
5
|
+
|
|
6
|
+
const ID = 'a1b2c3d4e5f6';
|
|
7
|
+
const SECRET = 'sk-the-real-key-the-space-never-sees';
|
|
8
|
+
const http = (status, body) => `HTTP/1.1 ${status} OK\r\nContent-Type: application/json\r\n\r\n${body}`;
|
|
9
|
+
const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A channel on a recording `exec`. `answers` are returned in turn, the last one repeats.
|
|
13
|
+
* Nothing sleeps: the clock moves by the pause in `wait`.
|
|
14
|
+
*/
|
|
15
|
+
const channelWith = (...answers) => {
|
|
16
|
+
const calls = [];
|
|
17
|
+
let clock = 1_000_000;
|
|
18
|
+
const exec = async (spaceId, argv, options) => {
|
|
19
|
+
calls.push({ spaceId, argv, options });
|
|
20
|
+
const answer = answers.length > 1 ? answers.shift() : answers[0];
|
|
21
|
+
if (answer instanceof Error) throw answer;
|
|
22
|
+
return answer;
|
|
23
|
+
};
|
|
24
|
+
const wait = async (milliseconds) => { clock += milliseconds; };
|
|
25
|
+
return { channel: createGatekeeperChannel({ exec, wait, now: () => clock }), calls };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('gatekeeper channel: the program', () => {
|
|
29
|
+
it('is the file beside this module, and it is one self-contained script', () => {
|
|
30
|
+
expect(GATEKEEPER_PROGRAM).toContain("require('node:net')");
|
|
31
|
+
expect(GATEKEEPER_PROGRAM.length).toBeGreaterThan(2_000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('travels on stdin into the tmpfs, through a temporary name, and is in no argument', async () => {
|
|
35
|
+
const { channel, calls } = channelWith(ok());
|
|
36
|
+
await channel.writeProgram(ID);
|
|
37
|
+
|
|
38
|
+
expect(calls).toEqual([{
|
|
39
|
+
spaceId: ID,
|
|
40
|
+
argv: [
|
|
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',
|
|
43
|
+
],
|
|
44
|
+
options: { stdin: GATEKEEPER_PROGRAM, target: 'gatekeeper' },
|
|
45
|
+
}]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('says what failed inside the gatekeeper', async () => {
|
|
49
|
+
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') });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('gatekeeper channel: control requests', () => {
|
|
55
|
+
it('reaches the control listener on the gatekeeper loopback, with --disable and --noproxy first', async () => {
|
|
56
|
+
const { channel, calls } = channelWith(ok(http(200, '{"ok":true}')));
|
|
57
|
+
await channel.setNetwork(ID, { mode: 'allowlist', domains: ['api.anthropic.com'] });
|
|
58
|
+
|
|
59
|
+
expect(calls[0].options.target).toBe('gatekeeper');
|
|
60
|
+
expect(calls[0].argv.slice(0, 4)).toEqual(['/usr/bin/curl', '--disable', '--noproxy', '*']);
|
|
61
|
+
expect(calls[0].options.stdin.split('\n')).toEqual([
|
|
62
|
+
'url = "http://127.0.0.1:9099/network"',
|
|
63
|
+
'request = "POST"',
|
|
64
|
+
'header = "Expect:"',
|
|
65
|
+
'header = "Content-Type: application/json"',
|
|
66
|
+
'data-raw = "{\\"mode\\":\\"allowlist\\",\\"domains\\":[\\"api.anthropic.com\\"]}"',
|
|
67
|
+
'',
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps a grant\'s secret out of every argument list, on the host and inside', async () => {
|
|
72
|
+
const { channel, calls } = channelWith(ok(http(200, '{"ok":true}')));
|
|
73
|
+
await channel.addGrant(ID, { id: 'anthropic', upstream: 'https://api.anthropic.com/v1', header: 'x-api-key', secret: SECRET });
|
|
74
|
+
|
|
75
|
+
expect(calls[0].argv.join(' ')).not.toContain(SECRET);
|
|
76
|
+
expect(calls[0].options.stdin).toContain(SECRET);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reports a refusal from the control channel, with what it said', async () => {
|
|
80
|
+
const { channel } = channelWith(ok(http(400, '{"error":"mode is \\"allowlist\\" or \\"open\\""}')));
|
|
81
|
+
await expect(channel.setNetwork(ID, { mode: 'everything', domains: [] })).rejects.toMatchObject({
|
|
82
|
+
code: 'gatekeeper_refused',
|
|
83
|
+
details: { status: 400 },
|
|
84
|
+
message: expect.stringContaining('allowlist'),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('says when nothing answers, with the curl exit code', async () => {
|
|
89
|
+
const { channel } = channelWith({ code: 7, stdout: '', stderr: 'curl: (7) Failed to connect to 127.0.0.1 port 9099\n' });
|
|
90
|
+
await expect(channel.readJournal(ID)).rejects.toMatchObject({ code: 'gatekeeper_unreachable', details: { curlExitCode: 7 } });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([
|
|
94
|
+
['an answer that is not HTTP', ok('garbage')],
|
|
95
|
+
['more output than the host accepts', Object.assign(new Error('too much'), { code: 'command_output_too_large' })],
|
|
96
|
+
])('rejects %s as unreadable', async (title, answer) => {
|
|
97
|
+
const { channel } = channelWith(answer);
|
|
98
|
+
const error = await channel.readJournal(ID).catch((caught) => caught);
|
|
99
|
+
expect(error).toBeInstanceOf(SpaceError);
|
|
100
|
+
expect(error.code).toBe('gatekeeper_answer_unreadable');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('maps an exec that timed out to "unreachable", never to an interrupted Docker step', async () => {
|
|
104
|
+
for (const code of ['command_timeout', 'command_killed']) {
|
|
105
|
+
const { channel } = channelWith(Object.assign(new Error('docker exec did not finish'), { code }));
|
|
106
|
+
const error = await channel.readJournal(ID).catch((caught) => caught);
|
|
107
|
+
expect(error.code).toBe('gatekeeper_unreachable');
|
|
108
|
+
expect(error.details.execFailed).toBe(false);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('lets a failure of the place through', async () => {
|
|
113
|
+
const failure = Object.assign(new Error('spawn docker ENOENT'), { code: 'command_spawn_failed' });
|
|
114
|
+
const { channel } = channelWith(failure);
|
|
115
|
+
await expect(channel.readJournal(ID)).rejects.toBe(failure);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('gatekeeper channel: journal', () => {
|
|
120
|
+
const record = { at: '2026-09-20T10:00:00.000Z', listener: 'corridor', host: 'api.anthropic.com', port: 443, decision: 'allow' };
|
|
121
|
+
|
|
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 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('keeps the five fields of a record and nothing else the gatekeeper sends', async () => {
|
|
128
|
+
const hostile = { ...record, path: '/v1/messages?key=leak', body: 'a body', secret: SECRET, extra: { deep: true } };
|
|
129
|
+
const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [hostile], dropped: 0 }))));
|
|
130
|
+
|
|
131
|
+
const journal = await channel.readJournal(ID);
|
|
132
|
+
expect(Object.keys(journal.records[0])).toEqual(['at', 'listener', 'host', 'port', 'decision']);
|
|
133
|
+
expect(JSON.stringify(journal)).not.toContain(SECRET);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('caps the records it reads, and the length of every field', async () => {
|
|
137
|
+
// The gatekeeper keeps 500 records, so more than a thousand means something inside is wrong.
|
|
138
|
+
const many = Array.from({ length: 2_000 }, () => ({ ...record, host: 'x'.repeat(400) }));
|
|
139
|
+
const { channel } = channelWith(ok(http(200, JSON.stringify({ records: many, dropped: -1 }))));
|
|
140
|
+
|
|
141
|
+
const journal = await channel.readJournal(ID);
|
|
142
|
+
expect(journal.records).toHaveLength(1_000);
|
|
143
|
+
expect(journal.records[0].host).toHaveLength(256);
|
|
144
|
+
expect(journal.dropped).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('refuses a journal larger than the host reads, instead of spending the time on it', async () => {
|
|
148
|
+
const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [{ ...record, host: 'x'.repeat(2 * 1024 * 1024) }] }))));
|
|
149
|
+
await expect(channel.readJournal(ID)).rejects.toMatchObject({ code: 'gatekeeper_answer_unreadable' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each([
|
|
153
|
+
['a body that is not JSON', '<html>'],
|
|
154
|
+
['a journal without records', '{"dropped":0}'],
|
|
155
|
+
['records that are not a list', '{"records":"none"}'],
|
|
156
|
+
['null', 'null'],
|
|
157
|
+
])('rejects %s instead of answering an empty journal', async (title, body) => {
|
|
158
|
+
const { channel } = channelWith(ok(http(200, body)));
|
|
159
|
+
await expect(channel.readJournal(ID)).rejects.toMatchObject({ code: 'gatekeeper_answer_unreadable' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('turns a record of another shape into empty fields, and never throws', async () => {
|
|
163
|
+
const { channel } = channelWith(ok(http(200, JSON.stringify({ records: [null, 'text', { port: 'many' }] }))));
|
|
164
|
+
expect(await channel.readJournal(ID)).toEqual({
|
|
165
|
+
records: [
|
|
166
|
+
{ at: '', listener: '', host: '', port: 0, decision: '' },
|
|
167
|
+
{ at: '', listener: '', host: '', port: 0, decision: '' },
|
|
168
|
+
{ at: '', listener: '', host: '', port: 0, decision: '' },
|
|
169
|
+
],
|
|
170
|
+
dropped: 0,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('gatekeeper channel: waitUntilReady', () => {
|
|
176
|
+
const refused = { code: 7, stdout: '', stderr: 'curl: (7) Failed to connect' };
|
|
177
|
+
|
|
178
|
+
it('waits through "nothing listens yet" and resolves when the control channel answers', async () => {
|
|
179
|
+
const { channel, calls } = channelWith(refused, refused, ok(http(200, '{"ready":true}')));
|
|
180
|
+
await channel.waitUntilReady(ID);
|
|
181
|
+
expect(calls).toHaveLength(3);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('gives up at the deadline and says what it saw last', async () => {
|
|
185
|
+
const { channel } = channelWith(refused);
|
|
186
|
+
const error = await channel.waitUntilReady(ID).catch((caught) => caught);
|
|
187
|
+
expect(error).toBeInstanceOf(SpaceError);
|
|
188
|
+
expect(error.code).toBe('gatekeeper_not_ready');
|
|
189
|
+
expect(error.message).toMatch(/within 60 seconds \(.*Failed to connect/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it.each([
|
|
193
|
+
['a listener that does not speak HTTP', { code: 1, stdout: '', stderr: 'curl: (1) Received HTTP/0.9 when not allowed\n' }],
|
|
194
|
+
['an answer that is not HTTP', ok('garbage')],
|
|
195
|
+
['a 503 from the control channel', ok(http(503, '{}'))],
|
|
196
|
+
['an exec that timed out', Object.assign(new Error('docker exec did not finish'), { code: 'command_timeout' })],
|
|
197
|
+
])('counts %s as "not ready yet"', async (title, hostile) => {
|
|
198
|
+
const { channel, calls } = channelWith(hostile);
|
|
199
|
+
await expect(channel.waitUntilReady(ID)).rejects.toMatchObject({ code: 'gatekeeper_not_ready' });
|
|
200
|
+
expect(calls.length).toBeGreaterThan(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('stops at once when the container is gone', async () => {
|
|
204
|
+
const { channel, calls } = channelWith({ code: 1, stdout: '', stderr: 'Error response from daemon: container abc is not running' });
|
|
205
|
+
await expect(channel.waitUntilReady(ID)).rejects.toMatchObject({ code: 'gatekeeper_unreachable', details: { execFailed: true } });
|
|
206
|
+
expect(calls).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('asks with a short time limit, so a listener that never answers costs little', async () => {
|
|
210
|
+
const { channel, calls } = channelWith(ok(http(200, '{"ready":true}')));
|
|
211
|
+
await channel.waitUntilReady(ID);
|
|
212
|
+
expect(calls[0].argv).toContain('3');
|
|
213
|
+
expect(calls[0].options.timeoutMs).toBe(13_000);
|
|
214
|
+
});
|
|
215
|
+
});
|