@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,126 @@
|
|
|
1
|
+
// How the Docker place talks to the docker CLI: run, inspect, find by label, remove.
|
|
2
|
+
// Shared by the space operations in docker.js and the tools volume in docker-tools.js.
|
|
3
|
+
|
|
4
|
+
import { SpaceError } from '../errors.js';
|
|
5
|
+
|
|
6
|
+
const QUERY_TIMEOUT_MS = 30_000;
|
|
7
|
+
export const CHANGE_TIMEOUT_MS = 120_000;
|
|
8
|
+
// After a timed-out step the daemon may still finish it. A rollback sweeps again after this pause.
|
|
9
|
+
export const ROLLBACK_SETTLE_MS = 2_000;
|
|
10
|
+
|
|
11
|
+
// The CLI died in the middle of a step, so nobody knows whether the daemon finished it.
|
|
12
|
+
const INTERRUPTED_CODES = ['command_timeout', 'command_killed', 'command_output_too_large'];
|
|
13
|
+
export const isInterrupted = (error) => INTERRUPTED_CODES.includes(error?.code);
|
|
14
|
+
|
|
15
|
+
const NOT_FOUND_PATTERNS = [
|
|
16
|
+
/\bNo such (?:object|container|volume|network|image)\b/i,
|
|
17
|
+
/\b(?:container|volume|network)\b.*\bnot found\b/i,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const isNotFound = (result) => result.code !== 0 && NOT_FOUND_PATTERNS.some((pattern) => pattern.test(result.stderr));
|
|
21
|
+
|
|
22
|
+
// For a removal, "someone else is removing it right now" is as good as gone.
|
|
23
|
+
const REMOVAL_IN_PROGRESS = /removal of container .* is already in progress/i;
|
|
24
|
+
const isAlreadyGone = (result) => isNotFound(result) || (result.code !== 0 && REMOVAL_IN_PROGRESS.test(result.stderr));
|
|
25
|
+
|
|
26
|
+
const parseJson = (text) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new SpaceError('docker_output_unreadable', 'Docker printed something that is not JSON');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const inspectArgs = (kind, names) => (kind === 'container' ? ['inspect', '--type', 'container', ...names] : [kind, 'inspect', ...names]);
|
|
35
|
+
const listArgs = (kind, filters) => (kind === 'container'
|
|
36
|
+
? ['ps', '--all', ...filters, '--format', '{{.Names}}']
|
|
37
|
+
: [kind, 'ls', ...filters, '--format', '{{.Name}}']);
|
|
38
|
+
const removeArgs = (kind, name) => (kind === 'container' ? ['rm', '--force', name] : [kind, 'rm', name]);
|
|
39
|
+
|
|
40
|
+
export const entryLabels = (kind, entry) => (kind === 'container' ? entry.Config?.Labels : entry.Labels);
|
|
41
|
+
export const entryName = (entry) => String(entry.Name ?? '').replace(/^\//, '');
|
|
42
|
+
|
|
43
|
+
export const pause = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
|
|
44
|
+
|
|
45
|
+
export function createDockerEngine({ runCommand, dockerPath }) {
|
|
46
|
+
const run = (args, timeoutMs, options = {}) => runCommand(dockerPath, args, { ...options, timeoutMs });
|
|
47
|
+
|
|
48
|
+
const failure = (args, result) => new SpaceError(
|
|
49
|
+
'docker_command_failed',
|
|
50
|
+
`docker ${args.slice(0, 2).join(' ')} failed: ${result.stderr.trim() || `exit code ${result.code}`}`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const docker = async (args, timeoutMs) => {
|
|
54
|
+
const result = await run(args, timeoutMs);
|
|
55
|
+
if (result.code !== 0) {
|
|
56
|
+
throw failure(args, result);
|
|
57
|
+
}
|
|
58
|
+
return result.stdout;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The parsed inspect entry, or null when Docker has no such resource. */
|
|
62
|
+
const inspect = async (kind, name) => {
|
|
63
|
+
const args = inspectArgs(kind, [name]);
|
|
64
|
+
const result = await run(args, QUERY_TIMEOUT_MS);
|
|
65
|
+
if (isNotFound(result)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (result.code !== 0) {
|
|
69
|
+
throw failure(args, result);
|
|
70
|
+
}
|
|
71
|
+
return parseJson(result.stdout)[0];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The inspect entries of every resource of one kind that the label filters select. */
|
|
75
|
+
const findByLabel = async (kind, filters) => {
|
|
76
|
+
const names = (await docker(listArgs(kind, filters), QUERY_TIMEOUT_MS)).split('\n').filter(Boolean);
|
|
77
|
+
if (names.length === 0) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
// A name can vanish between the listing and the inspect. Docker then exits 1 but
|
|
81
|
+
// still prints the entries it found, and the rest must not suffer.
|
|
82
|
+
const args = inspectArgs(kind, names);
|
|
83
|
+
const result = await run(args, QUERY_TIMEOUT_MS);
|
|
84
|
+
if (result.code !== 0 && !isNotFound(result)) {
|
|
85
|
+
throw failure(args, result);
|
|
86
|
+
}
|
|
87
|
+
return parseJson(result.stdout);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const removeWith = async (args, kind, name) => {
|
|
91
|
+
try {
|
|
92
|
+
const result = await run(args, CHANGE_TIMEOUT_MS);
|
|
93
|
+
return result.code === 0 || isAlreadyGone(result) ? null : { kind, name, message: result.stderr.trim() };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { kind, name, message: error.message };
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Null when the resource is gone afterwards, otherwise what went wrong. A container goes even while it runs. */
|
|
100
|
+
const removeOne = (kind, name) => removeWith(removeArgs(kind, name), kind, name);
|
|
101
|
+
|
|
102
|
+
/** The same for a container that must be stopped. Docker refuses a running one, and that refusal is the point. */
|
|
103
|
+
const removeStoppedContainer = (nameOrId) => removeWith(['rm', nameOrId], 'container', nameOrId);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Removes the named resources that exist, labelled or not. Only for names that the
|
|
107
|
+
* same call found absent a moment ago: a `docker create` or `docker run` that
|
|
108
|
+
* the daemon finishes late makes a missing `src=` volume again, without labels.
|
|
109
|
+
*/
|
|
110
|
+
const removeByName = async (names) => {
|
|
111
|
+
const failed = [];
|
|
112
|
+
for (const [kind, name] of names) {
|
|
113
|
+
try {
|
|
114
|
+
if (await inspect(kind, name)) {
|
|
115
|
+
const problem = await removeOne(kind, name);
|
|
116
|
+
if (problem) failed.push(problem);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
failed.push({ kind, name, message: error.message });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return failed;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return { run, docker, failure, inspect, findByLabel, removeOne, removeStoppedContainer, removeByName };
|
|
126
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Test support for the suites that talk to a real Docker daemon.
|
|
2
|
+
// They run only with OPENCHAMBER_TEST_DOCKER=1.
|
|
3
|
+
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { expect } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { LABEL_MARKER, LABEL_OWNER, ROLE_GATEKEEPER, ROLE_NETWORK, ROLE_OUTER_NETWORK, ROLE_SPACE, spaceResourceName } from '../labels.js';
|
|
9
|
+
import { runCommand } from '../run-command.js';
|
|
10
|
+
import { createRegistryToolsSource, readHostToolVersions } from '../tools.js';
|
|
11
|
+
import { SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
|
|
12
|
+
|
|
13
|
+
export const LIVE_DOCKER_ENABLED = process.env.OPENCHAMBER_TEST_DOCKER === '1';
|
|
14
|
+
|
|
15
|
+
const LISTINGS = [
|
|
16
|
+
['ps', '--all', '--format', '{{.Names}}'],
|
|
17
|
+
['network', 'ls', '--format', '{{.Name}}'],
|
|
18
|
+
['volume', 'ls', '--format', '{{.Name}}'],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const HOST_LISTENER = (port) => `
|
|
22
|
+
const routes = require('node:fs').readFileSync('/proc/net/fib_trie', 'utf8');
|
|
23
|
+
const local = [...routes.matchAll(/\\|-- (\\d+\\.\\d+\\.\\d+\\.\\d+)\\n\\s+\\/32 host LOCAL/g)].map((match) => match[1]);
|
|
24
|
+
const addresses = [...new Set(local)].filter((address) => !address.startsWith('127.'));
|
|
25
|
+
require('node:net').createServer((socket) => { socket.on('error', () => {}); socket.end('host'); }).listen(${port}, '0.0.0.0', () => console.log(JSON.stringify(addresses)));
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A Docker place with an owner id of its own, so parallel runs and real spaces stay apart.
|
|
30
|
+
* The owner also gets a tools volume of its own, so the first create of every live file pays for one fill.
|
|
31
|
+
* `placeWith(toolsSource)` makes another place for the same owner, as a host with other tools would be.
|
|
32
|
+
*/
|
|
33
|
+
export function createLiveDockerPlace({ toolsSource = createRegistryToolsSource(readHostToolVersions()) } = {}) {
|
|
34
|
+
const owner = `test-${crypto.randomBytes(6).toString('hex')}`;
|
|
35
|
+
const placeWith = (source) => createDockerPlace({ runCommand, dockerPath: 'docker', owner, toolsSource: source });
|
|
36
|
+
const place = placeWith(toolsSource);
|
|
37
|
+
const ownerFilter = ['--filter', `label=${LABEL_MARKER}`, '--filter', `label=${LABEL_OWNER}=${owner}`];
|
|
38
|
+
// Helper containers carry the marker and the owner, so the leftover check sees them. They have no space id.
|
|
39
|
+
const helperLabels = ['--label', `${LABEL_MARKER}=true`, '--label', `${LABEL_OWNER}=${owner}`];
|
|
40
|
+
|
|
41
|
+
const docker = async (args, options) => {
|
|
42
|
+
const result = await runCommand('docker', args, options);
|
|
43
|
+
expect(result.code, `docker ${args[0]}: ${result.stderr}`).toBe(0);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const leftovers = async () => {
|
|
48
|
+
const names = [];
|
|
49
|
+
for (const listing of LISTINGS) {
|
|
50
|
+
names.push(...(await docker([...listing, ...ownerFilter])).stdout.split('\n').filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const host = {
|
|
56
|
+
runUnrestricted: (script) => docker(['run', '--rm', ...helperLabels, SPACE_BASE_IMAGE, 'node', '-e', script], { timeoutMs: 120_000 }),
|
|
57
|
+
|
|
58
|
+
// `--network host` puts the listener on the Docker host itself: the Colima VM here, the machine on Linux.
|
|
59
|
+
// It prints every IPv4 address of that host first, read from the kernel's local route table.
|
|
60
|
+
// `os.networkInterfaces()` would miss a bridge that has no running container yet. On an engine that ignores the isolated gateway mode,
|
|
61
|
+
// that list holds the gateway of the space's own bridge, which is the address the leak was measured on.
|
|
62
|
+
startHostListener: async () => {
|
|
63
|
+
const port = 20_000 + crypto.randomInt(20_000);
|
|
64
|
+
const name = `openchamber-test-listener-${owner}`;
|
|
65
|
+
await docker(['run', '--detach', '--name', name, ...helperLabels, '--network', 'host', SPACE_BASE_IMAGE, 'node', '-e', HOST_LISTENER(port)], { timeoutMs: 120_000 });
|
|
66
|
+
let addresses = [];
|
|
67
|
+
for (let attempt = 0; attempt < 40 && addresses.length === 0; attempt += 1) {
|
|
68
|
+
await new Promise((resolve) => { setTimeout(resolve, 250); });
|
|
69
|
+
const firstLine = (await docker(['logs', name])).stdout.split('\n')[0];
|
|
70
|
+
addresses = firstLine.startsWith('[') ? JSON.parse(firstLine) : [];
|
|
71
|
+
}
|
|
72
|
+
expect(addresses.length, 'the host listener reported no address').toBeGreaterThan(0);
|
|
73
|
+
// Docker Desktop injects the two names. They are tolerated when they do not resolve.
|
|
74
|
+
return { port, candidates: [...addresses, 'host.docker.internal', 'gateway.docker.internal'], stop: () => docker(['rm', '--force', name]) };
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// An internal network as the space network was before the isolated gateway mode. A probe from it
|
|
78
|
+
// must reach the host, or the host-listener test could never fail and proves nothing.
|
|
79
|
+
createPlainInternalNetwork: async () => {
|
|
80
|
+
const name = `openchamber-test-plain-${owner}`;
|
|
81
|
+
await docker(['network', 'create', '--driver', 'bridge', '--internal', '--ipv6=false', ...helperLabels, name]);
|
|
82
|
+
return {
|
|
83
|
+
run: (script) => docker(['run', '--rm', ...helperLabels, '--network', name, SPACE_BASE_IMAGE, 'node', '-e', script], { timeoutMs: 120_000 }),
|
|
84
|
+
remove: () => docker(['network', 'rm', name]),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Everything the place's own records show about the space container. A secret must not be in here.
|
|
89
|
+
spaceMetadata: async (spaceId) => (await docker(['inspect', spaceResourceName(spaceId, ROLE_SPACE)])).stdout,
|
|
90
|
+
|
|
91
|
+
// The same for the gatekeeper, which is where the secrets are.
|
|
92
|
+
gatekeeperMetadata: async (spaceId) => (await docker(['inspect', spaceResourceName(spaceId, ROLE_GATEKEEPER)])).stdout,
|
|
93
|
+
|
|
94
|
+
/** The space container's own address on its inner network, as Docker gave it. */
|
|
95
|
+
spaceAddress: async (spaceId) => {
|
|
96
|
+
const [entry] = JSON.parse((await docker(['inspect', spaceResourceName(spaceId, ROLE_SPACE)])).stdout);
|
|
97
|
+
return entry.NetworkSettings.Networks[spaceResourceName(spaceId, ROLE_NETWORK)]?.IPAddress ?? '';
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** The gatekeeper's own addresses, `{ inner, outer }`, as Docker gave them. */
|
|
101
|
+
gatekeeperAddresses: async (spaceId) => {
|
|
102
|
+
const [entry] = JSON.parse((await docker(['inspect', spaceResourceName(spaceId, ROLE_GATEKEEPER)])).stdout);
|
|
103
|
+
const networks = entry.NetworkSettings.Networks;
|
|
104
|
+
return {
|
|
105
|
+
inner: networks[spaceResourceName(spaceId, ROLE_NETWORK)]?.IPAddress ?? '',
|
|
106
|
+
outer: networks[spaceResourceName(spaceId, ROLE_OUTER_NETWORK)]?.IPAddress ?? '',
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A stand-in model provider on the space's outer network, where a real one would be. It
|
|
112
|
+
* answers with the sha256 of the credentials it saw, never with the credentials, so that a
|
|
113
|
+
* test can prove the real key travelled without handing it to the space.
|
|
114
|
+
*/
|
|
115
|
+
startWindowUpstream: async (spaceId) => {
|
|
116
|
+
const name = `openchamber-test-upstream-${owner}`;
|
|
117
|
+
const program = `
|
|
118
|
+
const crypto = require('node:crypto');
|
|
119
|
+
const hash = (value) => (value === undefined ? 'none' : 'sha256:' + crypto.createHash('sha256').update(String(value)).digest('hex'));
|
|
120
|
+
require('node:http').createServer((request, response) => {
|
|
121
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
122
|
+
response.end(JSON.stringify({
|
|
123
|
+
path: request.url,
|
|
124
|
+
authorization: hash(request.headers.authorization),
|
|
125
|
+
apiKey: hash(request.headers['x-api-key']),
|
|
126
|
+
headerNames: Object.keys(request.headers).sort(),
|
|
127
|
+
}));
|
|
128
|
+
}).listen(9000, '0.0.0.0', () => console.log('upstream listening'));
|
|
129
|
+
`;
|
|
130
|
+
await docker([
|
|
131
|
+
'run', '--detach', '--name', name, ...helperLabels,
|
|
132
|
+
'--network', spaceResourceName(spaceId, ROLE_OUTER_NETWORK), '--network-alias', 'upstream',
|
|
133
|
+
SPACE_BASE_IMAGE, 'node', '-e', program,
|
|
134
|
+
], { timeoutMs: 120_000 });
|
|
135
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
136
|
+
await new Promise((resolve) => { setTimeout(resolve, 250); });
|
|
137
|
+
if ((await docker(['logs', name])).stdout.includes('upstream listening')) break;
|
|
138
|
+
}
|
|
139
|
+
return { url: 'http://upstream:9000/v1', stop: () => docker(['rm', '--force', name]) };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A name of our own in the space's outer network, answered by Docker's embedded resolver with
|
|
144
|
+
* an address in that bridge's subnet — a private range, and therefore one the corridor must
|
|
145
|
+
* refuse. It exists so that the resolve-then-refuse path can be proved live without a
|
|
146
|
+
* third-party wildcard DNS service: when somebody else's resolver has a bad minute, a test
|
|
147
|
+
* that leans on it reports an inconclusive run and proves nothing.
|
|
148
|
+
*/
|
|
149
|
+
startBlockedName: async (spaceId) => {
|
|
150
|
+
const name = `openchamber-test-blocked-${owner}`;
|
|
151
|
+
// A last label that is not a number, because the corridor takes names and this one has to
|
|
152
|
+
// get past the name rule to reach the address rule that is under test.
|
|
153
|
+
const alias = 'blocked-by-address.test';
|
|
154
|
+
const network = spaceResourceName(spaceId, ROLE_OUTER_NETWORK);
|
|
155
|
+
await docker([
|
|
156
|
+
'run', '--detach', '--name', name, ...helperLabels,
|
|
157
|
+
'--network', network, '--network-alias', alias,
|
|
158
|
+
SPACE_BASE_IMAGE, 'sleep', '3600',
|
|
159
|
+
], { timeoutMs: 120_000 });
|
|
160
|
+
const [entry] = JSON.parse((await docker(['inspect', name])).stdout);
|
|
161
|
+
return {
|
|
162
|
+
alias,
|
|
163
|
+
address: entry.NetworkSettings.Networks[network]?.IPAddress ?? '',
|
|
164
|
+
stop: () => docker(['rm', '--force', name]),
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** The names of this owner's volumes. */
|
|
169
|
+
volumes: async () => (await docker([...LISTINGS[2], ...ownerFilter])).stdout.split('\n').filter(Boolean),
|
|
170
|
+
|
|
171
|
+
logBytes: async (spaceId) => {
|
|
172
|
+
const result = await docker(['logs', spaceResourceName(spaceId, ROLE_SPACE)], { maxOutputBytes: 128 * 1024 * 1024, timeoutMs: 120_000 });
|
|
173
|
+
return Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Removes every space, helper and tools volume of this run, then asserts that Docker holds nothing with this owner label. */
|
|
178
|
+
const dispose = async () => {
|
|
179
|
+
// Helpers first: one of them sits on a space's outer network, and a network with a
|
|
180
|
+
// container on it cannot go.
|
|
181
|
+
const spaces = new Set((await place.list()).flatMap((space) => [spaceResourceName(space.id, ROLE_SPACE), spaceResourceName(space.id, ROLE_GATEKEEPER)]));
|
|
182
|
+
for (const helper of (await docker([...LISTINGS[0], ...ownerFilter])).stdout.split('\n').filter(Boolean)) {
|
|
183
|
+
if (!spaces.has(helper)) await docker(['rm', '--force', helper]);
|
|
184
|
+
}
|
|
185
|
+
for (const space of await place.list()) {
|
|
186
|
+
await place.remove(space.id);
|
|
187
|
+
}
|
|
188
|
+
for (const helper of (await docker([...LISTINGS[0], ...ownerFilter])).stdout.split('\n').filter(Boolean)) {
|
|
189
|
+
await docker(['rm', '--force', helper]);
|
|
190
|
+
}
|
|
191
|
+
for (const network of (await docker([...LISTINGS[1], ...ownerFilter])).stdout.split('\n').filter(Boolean)) {
|
|
192
|
+
await docker(['network', 'rm', network]);
|
|
193
|
+
}
|
|
194
|
+
// What is left now are the tools volumes. No space mounts them any more.
|
|
195
|
+
for (const volume of await host.volumes()) {
|
|
196
|
+
await docker(['volume', 'rm', volume]);
|
|
197
|
+
}
|
|
198
|
+
expect(await leftovers()).toEqual([]);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return { place, placeWith, dispose, host, owner };
|
|
202
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The tools volume of the Docker place: one volume per owner and per tools content,
|
|
2
|
+
// filled once by a one-shot container and mounted read-only into spaces.
|
|
3
|
+
// A filled volume is never changed again. New content means a new key and a new volume.
|
|
4
|
+
|
|
5
|
+
import { SpaceError } from '../errors.js';
|
|
6
|
+
import { TOOLS_MARKER_MISSING_EXIT_CODE, buildToolsCheckRunArgs, buildToolsFillRunArgs } from '../hardening.js';
|
|
7
|
+
import {
|
|
8
|
+
ROLE_TOOLS,
|
|
9
|
+
ROLE_TOOLS_CHECK,
|
|
10
|
+
ROLE_TOOLS_FILL,
|
|
11
|
+
buildToolsLabels,
|
|
12
|
+
labelArgs,
|
|
13
|
+
parseToolsLabels,
|
|
14
|
+
toolsLabelFilterArgs,
|
|
15
|
+
toolsResourceName,
|
|
16
|
+
} from '../labels.js';
|
|
17
|
+
import { buildFillInput } from '../tools-filler.js';
|
|
18
|
+
import { requireToolsSource, toolsContentKey } from '../tools.js';
|
|
19
|
+
import { CHANGE_TIMEOUT_MS, ROLLBACK_SETTLE_MS, entryLabels, entryName, isInterrupted } from './docker-engine.js';
|
|
20
|
+
|
|
21
|
+
// A fill downloads about 440 MB. It took 36 seconds here and may take many minutes on a slow link.
|
|
22
|
+
const FILL_TIMEOUT_MS = 30 * 60_000;
|
|
23
|
+
// npm prints one line per warning, and a broken install prints a long error report.
|
|
24
|
+
const FILL_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
25
|
+
const ERROR_TAIL_CHARACTERS = 2_000;
|
|
26
|
+
|
|
27
|
+
export function createDockerTools({ engine, owner, toolsSource, image, now, wait }) {
|
|
28
|
+
const source = requireToolsSource(toolsSource);
|
|
29
|
+
const key = toolsContentKey(source, image);
|
|
30
|
+
const volume = toolsResourceName(owner, key);
|
|
31
|
+
let inFlight = null;
|
|
32
|
+
|
|
33
|
+
const labelsFor = (role) => labelArgs(buildToolsLabels({ role, owner, key, description: source.description, created: now().toISOString() }));
|
|
34
|
+
const oneShotArgs = (role) => ({ containerName: toolsResourceName(owner, key, role), labelArguments: labelsFor(role), toolsVolume: volume, image });
|
|
35
|
+
|
|
36
|
+
/** This owner's tools resources of one kind, checked again on the inspect result. */
|
|
37
|
+
const findOurs = async (kind, filter) => (await engine.findByLabel(kind, toolsLabelFilterArgs({ owner, ...filter })))
|
|
38
|
+
.filter((entry) => parseToolsLabels(entryLabels(kind, entry))?.owner === owner)
|
|
39
|
+
.map(entryName);
|
|
40
|
+
|
|
41
|
+
/** One-shot containers of this key that a killed CLI left behind. They would block the container name. */
|
|
42
|
+
const removeOneShots = async () => {
|
|
43
|
+
const failed = [];
|
|
44
|
+
for (const name of await findOurs('container', { key })) {
|
|
45
|
+
const problem = await engine.removeOne('container', name);
|
|
46
|
+
if (problem) failed.push(problem);
|
|
47
|
+
}
|
|
48
|
+
return failed;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the fill marker says: `filled`, `missing`, or `wrong` for a marker with other content.
|
|
53
|
+
* A docker CLI that cannot reach the daemon exits with 1, as `cat` does for a missing file, so
|
|
54
|
+
* "missing" has an exit code of its own. Every other failure rejects: it must never read as
|
|
55
|
+
* "not filled", because that would remove a filled volume.
|
|
56
|
+
*/
|
|
57
|
+
const readMarker = async () => {
|
|
58
|
+
const args = buildToolsCheckRunArgs(oneShotArgs(ROLE_TOOLS_CHECK));
|
|
59
|
+
const result = await engine.run(args, CHANGE_TIMEOUT_MS);
|
|
60
|
+
if (result.code === TOOLS_MARKER_MISSING_EXIT_CODE) {
|
|
61
|
+
return 'missing';
|
|
62
|
+
}
|
|
63
|
+
if (result.code !== 0) {
|
|
64
|
+
throw engine.failure(args, result);
|
|
65
|
+
}
|
|
66
|
+
return result.stdout.trim() === key ? 'filled' : 'wrong';
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Removes what a failed fill made. The volume goes by name: this call found the name free, or found it ours. */
|
|
70
|
+
const cleanUpFill = async () => {
|
|
71
|
+
try {
|
|
72
|
+
return [...(await removeOneShots()), ...(await engine.removeByName([['volume', volume]]))];
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return [{ kind: 'volume', name: volume, message: error.message }];
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const fill = async () => {
|
|
79
|
+
try {
|
|
80
|
+
await engine.docker(['volume', 'create', ...labelsFor(ROLE_TOOLS), volume], CHANGE_TIMEOUT_MS);
|
|
81
|
+
const result = await engine.run(buildToolsFillRunArgs(oneShotArgs(ROLE_TOOLS_FILL)), FILL_TIMEOUT_MS, {
|
|
82
|
+
stdin: buildFillInput({ key, packageJson: source.packageJson, files: source.files }),
|
|
83
|
+
maxOutputBytes: FILL_MAX_OUTPUT_BYTES,
|
|
84
|
+
});
|
|
85
|
+
if (result.code !== 0) {
|
|
86
|
+
throw new SpaceError(
|
|
87
|
+
'tools_fill_failed',
|
|
88
|
+
`Installing ${source.description} failed: ${result.stderr.trim().slice(-ERROR_TAIL_CHARACTERS) || `exit code ${result.code}`}. Common causes: the Docker machine cannot reach the npm registry, or this version was never published.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if ((await readMarker()) !== 'filled') {
|
|
92
|
+
throw new SpaceError('tools_fill_failed', `Installing ${source.description} ended without the fill marker, or the space user cannot read it.`);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
let rollbackFailures = await cleanUpFill();
|
|
96
|
+
// The CLI was killed, the daemon may still run the filler. Sweep again after a pause.
|
|
97
|
+
const uncertain = isInterrupted(error);
|
|
98
|
+
if (uncertain) {
|
|
99
|
+
await wait(ROLLBACK_SETTLE_MS);
|
|
100
|
+
rollbackFailures = await cleanUpFill();
|
|
101
|
+
}
|
|
102
|
+
const leftovers = rollbackFailures.length > 0
|
|
103
|
+
? ` Clean-up also failed for: ${rollbackFailures.map((item) => `${item.kind} ${item.name}`).join(', ')}.`
|
|
104
|
+
: '';
|
|
105
|
+
const wrapped = new SpaceError(error.code ?? 'tools_fill_failed', `Could not prepare the tools for spaces. ${error.message}${leftovers}`, { rollbackFailures, uncertain });
|
|
106
|
+
wrapped.cause = error;
|
|
107
|
+
throw wrapped;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Removes this owner's other tools volumes. Docker refuses a volume that a container
|
|
113
|
+
* still mounts, and that refusal means "a space still runs on it, keep it".
|
|
114
|
+
* Nothing here may fail the call that asked for it.
|
|
115
|
+
*/
|
|
116
|
+
const prune = async () => {
|
|
117
|
+
try {
|
|
118
|
+
for (const name of await findOurs('volume', { role: ROLE_TOOLS })) {
|
|
119
|
+
if (name !== volume) await engine.removeOne('volume', name);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// The next fill or the next recreate tries again.
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const ensureOnce = async () => {
|
|
127
|
+
const existing = await engine.inspect('volume', volume);
|
|
128
|
+
const labels = parseToolsLabels(existing?.Labels);
|
|
129
|
+
// `docker volume create` succeeds silently on an existing name, so a stranger's volume is never adopted.
|
|
130
|
+
if (existing && (labels?.role !== ROLE_TOOLS || labels?.owner !== owner || labels?.key !== key)) {
|
|
131
|
+
throw new SpaceError('tools_volume_not_ours', `Docker already has a volume named ${volume} that this OpenChamber installation did not create. Remove it, then try again.`);
|
|
132
|
+
}
|
|
133
|
+
const leftovers = await removeOneShots();
|
|
134
|
+
if (leftovers.length > 0) {
|
|
135
|
+
throw new SpaceError('tools_cleanup_failed', `Could not remove an old tools container: ${leftovers.map((item) => `${item.name} (${item.message})`).join(', ')}`);
|
|
136
|
+
}
|
|
137
|
+
if (existing) {
|
|
138
|
+
const marker = await readMarker();
|
|
139
|
+
if (marker === 'filled') {
|
|
140
|
+
return volume;
|
|
141
|
+
}
|
|
142
|
+
// Ours by label. No marker means a fill was interrupted. A wrong or empty marker means the
|
|
143
|
+
// Docker machine went down right after a fill, or something else wrote here. Either way the
|
|
144
|
+
// content cannot be trusted, so start over. Docker refuses while any container mounts the volume.
|
|
145
|
+
const problem = await engine.removeOne('volume', volume);
|
|
146
|
+
if (problem && marker === 'wrong') {
|
|
147
|
+
throw new SpaceError(
|
|
148
|
+
'tools_marker_mismatch',
|
|
149
|
+
`The tools for spaces are damaged and must be installed again, but spaces still use them. Apply or discard the work in the spaces that were made with ${source.description}, remove those spaces, then try again.`,
|
|
150
|
+
{ volume, reason: problem.message },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (problem) {
|
|
154
|
+
throw new SpaceError('tools_volume_stuck', `The tools volume ${volume} was never filled to the end and could not be removed: ${problem.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await fill();
|
|
158
|
+
await prune();
|
|
159
|
+
return volume;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Resolves the name of the filled tools volume. Concurrent calls share one run, so one key is never filled twice at once. */
|
|
163
|
+
const ensure = () => {
|
|
164
|
+
inFlight ??= ensureOnce().finally(() => { inFlight = null; });
|
|
165
|
+
return inFlight;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* For the failure path of a create that got `name` back from `ensure` as ours and filled.
|
|
170
|
+
* If another process pruned it in between, `docker create` made it again without labels, and
|
|
171
|
+
* every later call would refuse it as a stranger's. A volume with no labels at all is that
|
|
172
|
+
* leftover, and goes by name. A volume with labels, ours or anyone's, is never touched here.
|
|
173
|
+
*/
|
|
174
|
+
const removeIfUnlabelled = async (name) => {
|
|
175
|
+
try {
|
|
176
|
+
const entry = await engine.inspect('volume', name);
|
|
177
|
+
const unlabelled = entry && Object.keys(entry.Labels ?? {}).length === 0;
|
|
178
|
+
const problem = unlabelled ? await engine.removeOne('volume', name) : null;
|
|
179
|
+
return problem ? [problem] : [];
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return [{ kind: 'volume', name, message: error.message }];
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return { ensure, prune, removeIfUnlabelled };
|
|
186
|
+
}
|