@vantran-se/openchamber-web 1.24.4 → 2.0.1
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-ntXIbhV2.js} +1 -1
- package/dist/assets/DiffView-CjxUovbM.js +4 -0
- package/dist/assets/FilesView-DFWD0RQR.js +17 -0
- package/dist/assets/{GitView-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
- package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
- package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
- package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-3sGwqexh.js} +1 -1
- package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
- package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
- package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
- package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
- package/dist/assets/PlanView-BCk_VobP.js +5 -0
- package/dist/assets/PluginPane-BPP1dbck.js +3 -0
- package/dist/assets/SettingsView-kMmrpn43.js +29 -0
- package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
- package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
- package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
- package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
- package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
- package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
- package/dist/assets/de-CndEg67T.js +10 -0
- package/dist/assets/es-DglvPlaC.js +16 -0
- package/dist/assets/fr-DejPCpbS.js +14 -0
- package/dist/assets/index-C2r64QfD.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-D47a6hMP.js} +1 -1
- package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-B-mGgeKl.js} +1 -1
- package/dist/assets/ja-CVpQawCd.js +16 -0
- package/dist/assets/ko-Bw446h9j.js +16 -0
- package/dist/assets/main-Bz4WWXbr.js +2 -0
- package/dist/assets/main-oreqDsOV.js +896 -0
- package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-D2e5sp9y.js} +4 -4
- package/dist/assets/miniChat-D1v2gsBI.js +2 -0
- package/dist/assets/mobile-DBj3lCf2.js +2 -0
- package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-BpeUOV91.js} +3 -3
- package/dist/assets/pl-BYNlPdhm.js +16 -0
- package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
- package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
- package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
- package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
- package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
- package/dist/assets/tr-DzmbxLy-.js +7 -0
- package/dist/assets/uk-C7vQnwro.js +16 -0
- package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
- package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
- package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
- package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-CQa7_1cr.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-B6tKQyJN.js} +2 -2
- package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
- package/dist/assets/zh-TW-DtII5bDb.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,234 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { SpaceError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const LABEL_MARKER = 'openchamber.space';
|
|
6
|
+
const LABEL_ID = 'openchamber.space.id';
|
|
7
|
+
const LABEL_ROLE = 'openchamber.space.role';
|
|
8
|
+
export const LABEL_OWNER = 'openchamber.space.owner';
|
|
9
|
+
const LABEL_PROJECT = 'openchamber.space.project';
|
|
10
|
+
const LABEL_NAME = 'openchamber.space.name';
|
|
11
|
+
const LABEL_CREATED = 'openchamber.space.created';
|
|
12
|
+
|
|
13
|
+
const MARKER_VALUE = 'true';
|
|
14
|
+
|
|
15
|
+
export const ROLE_SPACE = 'space';
|
|
16
|
+
export const ROLE_SETUP = 'setup';
|
|
17
|
+
export const ROLE_NETWORK = 'network';
|
|
18
|
+
export const ROLE_VOLUME = 'volume';
|
|
19
|
+
// Since stage 2 every space also has a gatekeeper container and an outer network of its own.
|
|
20
|
+
// The inner network keeps the plain `network` role, so nothing that existed changes name.
|
|
21
|
+
export const ROLE_GATEKEEPER = 'gatekeeper';
|
|
22
|
+
export const ROLE_OUTER_NETWORK = 'outer-network';
|
|
23
|
+
const ROLES = new Set([ROLE_SPACE, ROLE_SETUP, ROLE_NETWORK, ROLE_VOLUME, ROLE_GATEKEEPER, ROLE_OUTER_NETWORK]);
|
|
24
|
+
|
|
25
|
+
// The tools volume and its two one-shot containers belong to an owner, not to a space.
|
|
26
|
+
// They carry no space id, so parseSpaceLabels returns null for them and `list` and `remove` pass them by.
|
|
27
|
+
const LABEL_TOOLS_KEY = 'openchamber.space.tools.key';
|
|
28
|
+
const LABEL_TOOLS_DESCRIPTION = 'openchamber.space.tools.description';
|
|
29
|
+
export const ROLE_TOOLS = 'tools';
|
|
30
|
+
export const ROLE_TOOLS_FILL = 'tools-fill';
|
|
31
|
+
export const ROLE_TOOLS_CHECK = 'tools-check';
|
|
32
|
+
const TOOLS_ROLES = new Set([ROLE_TOOLS, ROLE_TOOLS_FILL, ROLE_TOOLS_CHECK]);
|
|
33
|
+
const TOOLS_KEY_PATTERN = /^[0-9a-f]{16}$/;
|
|
34
|
+
|
|
35
|
+
const SPACE_ID_PATTERN = /^[0-9a-f]{12}$/;
|
|
36
|
+
// The owner travels inside `--filter label=key=value`, so it stays a plain token.
|
|
37
|
+
const OWNER_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
38
|
+
const PROJECT_PATTERN = /^[0-9a-f]{16}$/;
|
|
39
|
+
const SUFFIX_PATTERN = /^[a-z0-9]{1,16}$/;
|
|
40
|
+
// C0 controls, DEL, NEL, and the Unicode line and paragraph separators.
|
|
41
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f\x85\u2028\u2029]/;
|
|
42
|
+
const MAX_NAME_LENGTH = 100;
|
|
43
|
+
|
|
44
|
+
export const createSpaceId = () => crypto.randomBytes(6).toString('hex');
|
|
45
|
+
|
|
46
|
+
export const isSpaceId = (value) => SPACE_ID_PATTERN.test(value);
|
|
47
|
+
|
|
48
|
+
export function requireSpaceId(value) {
|
|
49
|
+
if (!isSpaceId(value)) {
|
|
50
|
+
throw new SpaceError('invalid_space_id', 'A space id is 12 lowercase hex characters');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isOwner = (value) => OWNER_PATTERN.test(value ?? '');
|
|
56
|
+
|
|
57
|
+
export function requireOwner(value) {
|
|
58
|
+
if (!isOwner(value)) {
|
|
59
|
+
throw new SpaceError('invalid_owner', 'The installation id may hold only letters, digits, dots, dashes and underscores, 64 at most');
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeSpaceName(value) {
|
|
65
|
+
const name = String(value ?? '').trim();
|
|
66
|
+
if (name.length === 0 || name.length > MAX_NAME_LENGTH || CONTROL_CHARACTERS.test(name)) {
|
|
67
|
+
throw new SpaceError('invalid_space_name', `A space name is 1 to ${MAX_NAME_LENGTH} characters on one line`);
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function hashProjectDirectory(directory) {
|
|
73
|
+
const text = String(directory ?? '');
|
|
74
|
+
if (text.trim().length === 0) {
|
|
75
|
+
throw new SpaceError('invalid_project_directory', 'A space needs the project directory it was created for');
|
|
76
|
+
}
|
|
77
|
+
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function spaceResourcePrefix(spaceId) {
|
|
81
|
+
return `openchamber-space-${requireSpaceId(spaceId)}-`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function spaceResourceName(spaceId, role, suffix = '') {
|
|
85
|
+
if (!ROLES.has(role)) {
|
|
86
|
+
throw new SpaceError('invalid_role', `Unknown space resource role '${role}'`);
|
|
87
|
+
}
|
|
88
|
+
if (suffix === '') {
|
|
89
|
+
return `${spaceResourcePrefix(spaceId)}${role}`;
|
|
90
|
+
}
|
|
91
|
+
if (!SUFFIX_PATTERN.test(suffix)) {
|
|
92
|
+
throw new SpaceError('invalid_resource_suffix', 'A resource suffix is 1 to 16 lowercase letters or digits');
|
|
93
|
+
}
|
|
94
|
+
return `${spaceResourcePrefix(spaceId)}${role}-${suffix}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildSpaceLabels({ id, role, owner, project, name, created }) {
|
|
98
|
+
if (!ROLES.has(role)) {
|
|
99
|
+
throw new SpaceError('invalid_role', `Unknown space resource role '${role}'`);
|
|
100
|
+
}
|
|
101
|
+
if (!PROJECT_PATTERN.test(project)) {
|
|
102
|
+
throw new SpaceError('invalid_project', 'A project label is the 16 character hash from hashProjectDirectory');
|
|
103
|
+
}
|
|
104
|
+
if (Number.isNaN(Date.parse(created))) {
|
|
105
|
+
throw new SpaceError('invalid_created', 'A creation time is an ISO date string');
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
[LABEL_MARKER]: MARKER_VALUE,
|
|
109
|
+
[LABEL_ID]: requireSpaceId(id),
|
|
110
|
+
[LABEL_ROLE]: role,
|
|
111
|
+
[LABEL_OWNER]: requireOwner(owner),
|
|
112
|
+
[LABEL_PROJECT]: project,
|
|
113
|
+
[LABEL_NAME]: normalizeSpaceName(name),
|
|
114
|
+
[LABEL_CREATED]: created,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const labelArgs = (labels) => Object.entries(labels).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
|
|
119
|
+
|
|
120
|
+
/** `--filter` arguments that select this owner's resources, optionally one space. */
|
|
121
|
+
export function labelFilterArgs({ owner, spaceId = null }) {
|
|
122
|
+
const filters = ['--filter', `label=${LABEL_MARKER}=${MARKER_VALUE}`, '--filter', `label=${LABEL_OWNER}=${requireOwner(owner)}`];
|
|
123
|
+
if (spaceId !== null) {
|
|
124
|
+
filters.push('--filter', `label=${LABEL_ID}=${requireSpaceId(spaceId)}`);
|
|
125
|
+
}
|
|
126
|
+
return filters;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reads our labels from the label object of a `docker inspect` entry.
|
|
131
|
+
* Returns null for anything that is not a well-formed resource of ours.
|
|
132
|
+
*/
|
|
133
|
+
export function parseSpaceLabels(labels) {
|
|
134
|
+
if (!labels || labels[LABEL_MARKER] !== MARKER_VALUE) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const id = labels[LABEL_ID];
|
|
138
|
+
const role = labels[LABEL_ROLE];
|
|
139
|
+
const owner = labels[LABEL_OWNER];
|
|
140
|
+
if (!isSpaceId(id) || !ROLES.has(role) || !isOwner(owner)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
role,
|
|
146
|
+
owner,
|
|
147
|
+
project: labels[LABEL_PROJECT] ?? '',
|
|
148
|
+
name: labels[LABEL_NAME] ?? '',
|
|
149
|
+
created: labels[LABEL_CREATED] ?? '',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isToolsKey = (value) => TOOLS_KEY_PATTERN.test(value ?? '');
|
|
154
|
+
|
|
155
|
+
export function requireToolsKey(value) {
|
|
156
|
+
if (!isToolsKey(value)) {
|
|
157
|
+
throw new SpaceError('invalid_tools_key', 'A tools key is 16 lowercase hex characters');
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One volume per owner and per tools content. A one-shot container adds its role as a suffix. */
|
|
163
|
+
export function toolsResourceName(owner, key, role = ROLE_TOOLS) {
|
|
164
|
+
if (!TOOLS_ROLES.has(role)) {
|
|
165
|
+
throw new SpaceError('invalid_role', `Unknown tools resource role '${role}'`);
|
|
166
|
+
}
|
|
167
|
+
const volume = `openchamber-tools-${requireOwner(owner)}-${requireToolsKey(key)}`;
|
|
168
|
+
return role === ROLE_TOOLS ? volume : `${volume}-${role.slice(ROLE_TOOLS.length + 1)}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The key inside a tools volume name of this owner, or null for any other name. */
|
|
172
|
+
export function toolsKeyFromVolumeName(name, owner) {
|
|
173
|
+
const prefix = `openchamber-tools-${requireOwner(owner)}-`;
|
|
174
|
+
const text = String(name ?? '');
|
|
175
|
+
const key = text.slice(prefix.length);
|
|
176
|
+
return text.startsWith(prefix) && isToolsKey(key) ? key : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildToolsLabels({ role, owner, key, description, created }) {
|
|
180
|
+
if (!TOOLS_ROLES.has(role)) {
|
|
181
|
+
throw new SpaceError('invalid_role', `Unknown tools resource role '${role}'`);
|
|
182
|
+
}
|
|
183
|
+
if (Number.isNaN(Date.parse(created))) {
|
|
184
|
+
throw new SpaceError('invalid_created', 'A creation time is an ISO date string');
|
|
185
|
+
}
|
|
186
|
+
// Same rule as a space name: one line, so it survives as a label value.
|
|
187
|
+
const text = String(description ?? '').trim();
|
|
188
|
+
if (text.length === 0 || text.length > MAX_NAME_LENGTH || CONTROL_CHARACTERS.test(text)) {
|
|
189
|
+
throw new SpaceError('invalid_tools_description', `A tools description is 1 to ${MAX_NAME_LENGTH} characters on one line`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
[LABEL_MARKER]: MARKER_VALUE,
|
|
193
|
+
[LABEL_ROLE]: role,
|
|
194
|
+
[LABEL_OWNER]: requireOwner(owner),
|
|
195
|
+
[LABEL_TOOLS_KEY]: requireToolsKey(key),
|
|
196
|
+
[LABEL_TOOLS_DESCRIPTION]: text,
|
|
197
|
+
[LABEL_CREATED]: created,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `--filter` arguments that select this owner's tools resources, optionally of one role and of one key. */
|
|
202
|
+
export function toolsLabelFilterArgs({ owner, role = null, key = null }) {
|
|
203
|
+
const filters = labelFilterArgs({ owner });
|
|
204
|
+
if (role !== null) {
|
|
205
|
+
filters.push('--filter', `label=${LABEL_ROLE}=${role}`);
|
|
206
|
+
}
|
|
207
|
+
if (key !== null) {
|
|
208
|
+
filters.push('--filter', `label=${LABEL_TOOLS_KEY}=${requireToolsKey(key)}`);
|
|
209
|
+
}
|
|
210
|
+
return filters;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reads the tools labels from the label object of a `docker inspect` entry.
|
|
215
|
+
* Returns null for anything that is not a well-formed tools resource of ours.
|
|
216
|
+
*/
|
|
217
|
+
export function parseToolsLabels(labels) {
|
|
218
|
+
if (!labels || labels[LABEL_MARKER] !== MARKER_VALUE) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const role = labels[LABEL_ROLE];
|
|
222
|
+
const owner = labels[LABEL_OWNER];
|
|
223
|
+
const key = labels[LABEL_TOOLS_KEY];
|
|
224
|
+
if (!TOOLS_ROLES.has(role) || !isOwner(owner) || !isToolsKey(key)) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
role,
|
|
229
|
+
owner,
|
|
230
|
+
key,
|
|
231
|
+
description: labels[LABEL_TOOLS_DESCRIPTION] ?? '',
|
|
232
|
+
created: labels[LABEL_CREATED] ?? '',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildSpaceLabels,
|
|
5
|
+
buildToolsLabels,
|
|
6
|
+
createSpaceId,
|
|
7
|
+
hashProjectDirectory,
|
|
8
|
+
isSpaceId,
|
|
9
|
+
labelArgs,
|
|
10
|
+
labelFilterArgs,
|
|
11
|
+
normalizeSpaceName,
|
|
12
|
+
parseSpaceLabels,
|
|
13
|
+
parseToolsLabels,
|
|
14
|
+
spaceResourceName,
|
|
15
|
+
toolsKeyFromVolumeName,
|
|
16
|
+
toolsLabelFilterArgs,
|
|
17
|
+
toolsResourceName,
|
|
18
|
+
} from './labels.js';
|
|
19
|
+
|
|
20
|
+
const ID = 'a1b2c3d4e5f6';
|
|
21
|
+
const FIELDS = {
|
|
22
|
+
id: ID,
|
|
23
|
+
role: 'space',
|
|
24
|
+
owner: 'install-a',
|
|
25
|
+
project: hashProjectDirectory('/home/me/project'),
|
|
26
|
+
name: 'Fix login',
|
|
27
|
+
created: '2026-09-19T10:00:00.000Z',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('space ids', () => {
|
|
31
|
+
it('creates 12 lowercase hex characters, different each time', () => {
|
|
32
|
+
const first = createSpaceId();
|
|
33
|
+
expect(first).toMatch(/^[0-9a-f]{12}$/);
|
|
34
|
+
expect(createSpaceId()).not.toBe(first);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it.each(['', 'A1B2C3D4E5F6', 'a1b2c3d4e5f', 'a1b2c3d4e5f6a', '../../../etc', 'a1b2c3d4e5f6\n', undefined, null])('rejects %j', (value) => {
|
|
38
|
+
expect(isSpaceId(value)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('hashProjectDirectory', () => {
|
|
43
|
+
it('is the first 16 hex characters of the sha256 of the directory', () => {
|
|
44
|
+
expect(hashProjectDirectory('/home/me/project')).toBe('225df3094012a213');
|
|
45
|
+
expect(hashProjectDirectory('/home/me/other')).not.toBe(hashProjectDirectory('/home/me/project'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects an empty directory', () => {
|
|
49
|
+
expect(() => hashProjectDirectory(' ')).toThrow(expect.objectContaining({ code: 'invalid_project_directory' }));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('spaceResourceName', () => {
|
|
54
|
+
it('is deterministic per id, role and suffix', () => {
|
|
55
|
+
expect(spaceResourceName(ID, 'space')).toBe(`openchamber-space-${ID}-space`);
|
|
56
|
+
expect(spaceResourceName(ID, 'network')).toBe(`openchamber-space-${ID}-network`);
|
|
57
|
+
expect(spaceResourceName(ID, 'volume', 'work')).toBe(`openchamber-space-${ID}-volume-work`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a bad id, role or suffix', () => {
|
|
61
|
+
expect(() => spaceResourceName('nope', 'space')).toThrow(expect.objectContaining({ code: 'invalid_space_id' }));
|
|
62
|
+
expect(() => spaceResourceName(ID, 'gateway')).toThrow(expect.objectContaining({ code: 'invalid_role' }));
|
|
63
|
+
expect(() => spaceResourceName(ID, 'volume', '../x')).toThrow(expect.objectContaining({ code: 'invalid_resource_suffix' }));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('normalizeSpaceName', () => {
|
|
68
|
+
it('trims and keeps punctuation', () => {
|
|
69
|
+
expect(normalizeSpaceName(' Fix a=b, "c" ')).toBe('Fix a=b, "c"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each(['', ' ', 'two\nlines', 'x'.repeat(101), undefined, 'a\u2028b', 'a\u2029b', 'a\u0085b', 'a\u007fb'])('rejects %j', (value) => {
|
|
73
|
+
expect(() => normalizeSpaceName(value)).toThrow(expect.objectContaining({ code: 'invalid_space_name' }));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('buildSpaceLabels', () => {
|
|
78
|
+
it('builds every label under openchamber.space', () => {
|
|
79
|
+
expect(buildSpaceLabels(FIELDS)).toEqual({
|
|
80
|
+
'openchamber.space': 'true',
|
|
81
|
+
'openchamber.space.id': ID,
|
|
82
|
+
'openchamber.space.role': 'space',
|
|
83
|
+
'openchamber.space.owner': 'install-a',
|
|
84
|
+
'openchamber.space.project': FIELDS.project,
|
|
85
|
+
'openchamber.space.name': 'Fix login',
|
|
86
|
+
'openchamber.space.created': '2026-09-19T10:00:00.000Z',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each([
|
|
91
|
+
['invalid_space_id', { id: 'nope' }],
|
|
92
|
+
['invalid_role', { role: 'gateway' }],
|
|
93
|
+
['invalid_owner', { owner: 'a b' }],
|
|
94
|
+
['invalid_owner', { owner: 'a,openchamber.space.id=x' }],
|
|
95
|
+
['invalid_project', { project: '/home/me/project' }],
|
|
96
|
+
['invalid_space_name', { name: '' }],
|
|
97
|
+
['invalid_created', { created: 'yesterday-ish' }],
|
|
98
|
+
])('rejects with %s', (code, change) => {
|
|
99
|
+
expect(() => buildSpaceLabels({ ...FIELDS, ...change })).toThrow(expect.objectContaining({ code }));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('turns into --label arguments and --filter arguments', () => {
|
|
103
|
+
expect(labelArgs({ a: '1', b: 'x=y' })).toEqual(['--label', 'a=1', '--label', 'b=x=y']);
|
|
104
|
+
expect(labelFilterArgs({ owner: 'install-a' })).toEqual([
|
|
105
|
+
'--filter', 'label=openchamber.space=true',
|
|
106
|
+
'--filter', 'label=openchamber.space.owner=install-a',
|
|
107
|
+
]);
|
|
108
|
+
expect(labelFilterArgs({ owner: 'install-a', spaceId: ID })).toEqual([
|
|
109
|
+
'--filter', 'label=openchamber.space=true',
|
|
110
|
+
'--filter', 'label=openchamber.space.owner=install-a',
|
|
111
|
+
'--filter', `label=openchamber.space.id=${ID}`,
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('parseSpaceLabels', () => {
|
|
117
|
+
it('round-trips a name with commas, equals signs and quotes', () => {
|
|
118
|
+
const name = 'Fix a=b, openchamber.space.id=ffffffffffff, "c"';
|
|
119
|
+
const labels = buildSpaceLabels({ ...FIELDS, name });
|
|
120
|
+
|
|
121
|
+
expect(parseSpaceLabels(labels)).toEqual({ ...FIELDS, name });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([
|
|
125
|
+
['no labels', null],
|
|
126
|
+
['no marker', { 'openchamber.space.id': ID }],
|
|
127
|
+
['a wrong marker value', { ...buildSpaceLabels(FIELDS), 'openchamber.space': 'yes' }],
|
|
128
|
+
['a malformed id', { ...buildSpaceLabels(FIELDS), 'openchamber.space.id': 'A1' }],
|
|
129
|
+
['an unknown role', { ...buildSpaceLabels(FIELDS), 'openchamber.space.role': 'gateway' }],
|
|
130
|
+
['no owner', { ...buildSpaceLabels(FIELDS), 'openchamber.space.owner': undefined }],
|
|
131
|
+
])('returns null for %s', (title, labels) => {
|
|
132
|
+
expect(parseSpaceLabels(labels)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('keeps a resource whose display labels are missing', () => {
|
|
136
|
+
const parsed = parseSpaceLabels({ 'openchamber.space': 'true', 'openchamber.space.id': ID, 'openchamber.space.role': 'volume', 'openchamber.space.owner': 'install-a' });
|
|
137
|
+
expect(parsed).toEqual({ id: ID, role: 'volume', owner: 'install-a', project: '', name: '', created: '' });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('tools labels and names', () => {
|
|
142
|
+
const KEY = '0123456789abcdef';
|
|
143
|
+
const TOOLS_FIELDS = { role: 'tools', owner: 'install-a', key: KEY, description: 'web 1.24.2, opencode 1.18.31', created: '2026-09-20T08:00:00.000Z' };
|
|
144
|
+
|
|
145
|
+
it('names the volume after the owner and the key, and a one-shot after the volume', () => {
|
|
146
|
+
expect(toolsResourceName('install-a', KEY)).toBe(`openchamber-tools-install-a-${KEY}`);
|
|
147
|
+
expect(toolsResourceName('install-a', KEY, 'tools-fill')).toBe(`openchamber-tools-install-a-${KEY}-fill`);
|
|
148
|
+
expect(toolsResourceName('install-a', KEY, 'tools-check')).toBe(`openchamber-tools-install-a-${KEY}-check`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a bad owner, key or role in a name', () => {
|
|
152
|
+
expect(() => toolsResourceName('a b', KEY)).toThrow(expect.objectContaining({ code: 'invalid_owner' }));
|
|
153
|
+
expect(() => toolsResourceName('install-a', '../x')).toThrow(expect.objectContaining({ code: 'invalid_tools_key' }));
|
|
154
|
+
expect(() => toolsResourceName('install-a', KEY, 'space')).toThrow(expect.objectContaining({ code: 'invalid_role' }));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('reads the key back from a volume name of this owner only', () => {
|
|
158
|
+
expect(toolsKeyFromVolumeName(`openchamber-tools-install-a-${KEY}`, 'install-a')).toBe(KEY);
|
|
159
|
+
// An owner whose id is the start of another owner's id must not match that owner's volumes.
|
|
160
|
+
expect(toolsKeyFromVolumeName(`openchamber-tools-install-a-${KEY}`, 'install')).toBeNull();
|
|
161
|
+
expect(toolsKeyFromVolumeName(`openchamber-tools-install-b-${KEY}`, 'install-a')).toBeNull();
|
|
162
|
+
expect(toolsKeyFromVolumeName(`openchamber-tools-install-a-${KEY}-fill`, 'install-a')).toBeNull();
|
|
163
|
+
expect(toolsKeyFromVolumeName(`openchamber-space-${ID}-volume-work`, 'install-a')).toBeNull();
|
|
164
|
+
expect(toolsKeyFromVolumeName(undefined, 'install-a')).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('builds the marker, the owner, the role, the key, a description and the time, and no space id', () => {
|
|
168
|
+
expect(buildToolsLabels(TOOLS_FIELDS)).toEqual({
|
|
169
|
+
'openchamber.space': 'true',
|
|
170
|
+
'openchamber.space.role': 'tools',
|
|
171
|
+
'openchamber.space.owner': 'install-a',
|
|
172
|
+
'openchamber.space.tools.key': KEY,
|
|
173
|
+
'openchamber.space.tools.description': 'web 1.24.2, opencode 1.18.31',
|
|
174
|
+
'openchamber.space.created': '2026-09-20T08:00:00.000Z',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it.each([
|
|
179
|
+
['invalid_role', { role: 'volume' }],
|
|
180
|
+
['invalid_owner', { owner: 'a,b' }],
|
|
181
|
+
['invalid_tools_key', { key: 'ABCDEF0123456789' }],
|
|
182
|
+
['invalid_tools_description', { description: 'two\nlines' }],
|
|
183
|
+
['invalid_tools_description', { description: '' }],
|
|
184
|
+
['invalid_created', { created: 'soon' }],
|
|
185
|
+
])('rejects with %s', (code, change) => {
|
|
186
|
+
expect(() => buildToolsLabels({ ...TOOLS_FIELDS, ...change })).toThrow(expect.objectContaining({ code }));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('is invisible to the space label parser, so `list` and `remove` pass a tools volume by', () => {
|
|
190
|
+
expect(parseSpaceLabels(buildToolsLabels(TOOLS_FIELDS))).toBeNull();
|
|
191
|
+
expect(parseToolsLabels(buildSpaceLabels(FIELDS))).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('round-trips through the tools label parser', () => {
|
|
195
|
+
expect(parseToolsLabels(buildToolsLabels(TOOLS_FIELDS))).toEqual(TOOLS_FIELDS);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each([
|
|
199
|
+
['no labels', null],
|
|
200
|
+
['no marker', { ...buildToolsLabels(TOOLS_FIELDS), 'openchamber.space': undefined }],
|
|
201
|
+
['a malformed key', { ...buildToolsLabels(TOOLS_FIELDS), 'openchamber.space.tools.key': 'latest' }],
|
|
202
|
+
['no owner', { ...buildToolsLabels(TOOLS_FIELDS), 'openchamber.space.owner': undefined }],
|
|
203
|
+
])('returns null for %s', (title, labels) => {
|
|
204
|
+
expect(parseToolsLabels(labels)).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('filters by owner, and by role and key when asked', () => {
|
|
208
|
+
expect(toolsLabelFilterArgs({ owner: 'install-a', role: 'tools' })).toEqual([
|
|
209
|
+
'--filter', 'label=openchamber.space=true',
|
|
210
|
+
'--filter', 'label=openchamber.space.owner=install-a',
|
|
211
|
+
'--filter', 'label=openchamber.space.role=tools',
|
|
212
|
+
]);
|
|
213
|
+
expect(toolsLabelFilterArgs({ owner: 'install-a', key: KEY })).toEqual([
|
|
214
|
+
'--filter', 'label=openchamber.space=true',
|
|
215
|
+
'--filter', 'label=openchamber.space.owner=install-a',
|
|
216
|
+
'--filter', `label=openchamber.space.tools.key=${KEY}`,
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// What a space looks like from the inside. Every place builds its container from these
|
|
2
|
+
// facts, and everything written on top of `exec` relies on them.
|
|
3
|
+
|
|
4
|
+
import { SpaceError } from './errors.js';
|
|
5
|
+
import { requireSpaceId } from './labels.js';
|
|
6
|
+
|
|
7
|
+
export const SPACE_USER = '1000:1000';
|
|
8
|
+
export const SPACE_HOME = '/home/space';
|
|
9
|
+
|
|
10
|
+
export const spaceWorkPath = (spaceId) => `/spaces/${requireSpaceId(spaceId)}`;
|
|
11
|
+
|
|
12
|
+
// The project's folder inside a space is named after the project directory on the host, restricted
|
|
13
|
+
// to characters that need no quoting anywhere. A leading dot is refused, so the name never collides
|
|
14
|
+
// with the history side repository below, and `node_modules` is refused because the OpenCode plugin
|
|
15
|
+
// link lives at `/spaces/<id>/node_modules`.
|
|
16
|
+
const PROJECT_FOLDER_UNSAFE = /[^A-Za-z0-9._-]+/g;
|
|
17
|
+
const PROJECT_FOLDER_MAX_LENGTH = 64;
|
|
18
|
+
const PROJECT_FOLDER_FALLBACK = 'project';
|
|
19
|
+
|
|
20
|
+
/** The folder name of a project inside a space, derived from the base name of its directory on the host. */
|
|
21
|
+
export function projectFolderName(projectDirectory) {
|
|
22
|
+
const base = String(projectDirectory ?? '').split(/[\\/]+/).filter(Boolean).pop() ?? '';
|
|
23
|
+
const name = base.replace(PROJECT_FOLDER_UNSAFE, '-').replace(/^[.-]+/, '').slice(0, PROJECT_FOLDER_MAX_LENGTH);
|
|
24
|
+
if (!/[A-Za-z0-9]/.test(name) || name.toLowerCase() === 'node_modules') {
|
|
25
|
+
return PROJECT_FOLDER_FALLBACK;
|
|
26
|
+
}
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where the project's code lives inside a space: a plain non-bare repository. */
|
|
31
|
+
export const spaceProjectPath = (spaceId, projectDirectory) => `${spaceWorkPath(spaceId)}/${projectFolderName(projectDirectory)}`;
|
|
32
|
+
|
|
33
|
+
/** A project path of this space, one folder deep with a name the rule above could have made, or a refusal. */
|
|
34
|
+
export function requireSpaceProjectPath(spaceId, value) {
|
|
35
|
+
const text = String(value ?? '');
|
|
36
|
+
const prefix = `${spaceWorkPath(spaceId)}/`;
|
|
37
|
+
const name = text.slice(prefix.length);
|
|
38
|
+
if (!text.startsWith(prefix) || name === '' || projectFolderName(name) !== name) {
|
|
39
|
+
throw new SpaceError('invalid_space_path', `A project path in space ${spaceId} is ${prefix}<folder>, the spacePath that code in returned`);
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The side repository that the history travels through. It starts with a dot, which no project
|
|
45
|
+
// folder name can, and it exists only while the history is on its way.
|
|
46
|
+
export const spaceHistoryPath = (spaceId) => `${spaceWorkPath(spaceId)}/.openchamber-history.git`;
|
|
47
|
+
|
|
48
|
+
// The tools volume: a plain npm project, mounted read-only.
|
|
49
|
+
export const TOOLS_MOUNT_PATH = '/opt/openchamber-tools';
|
|
50
|
+
export const TOOLS_BIN_PATH = `${TOOLS_MOUNT_PATH}/node_modules/.bin`;
|
|
51
|
+
export const TOOLS_PLUGIN_PATH = `${TOOLS_MOUNT_PATH}/node_modules/@opencode/plugin`;
|
|
52
|
+
// The filler writes this file last. A volume without it was never filled to the end.
|
|
53
|
+
export const TOOLS_MARKER_PATH = `${TOOLS_MOUNT_PATH}/.filled`;
|
|
54
|
+
|
|
55
|
+
// The server inside listens on loopback only, so nothing faces the space network.
|
|
56
|
+
// The port is an unusual one, because the agent's own dev servers share this loopback.
|
|
57
|
+
export const SPACE_SERVER_HOST = '127.0.0.1';
|
|
58
|
+
export const SPACE_SERVER_PORT = 27600;
|
|
59
|
+
|
|
60
|
+
export const SPACE_TOKEN_DIRECTORY = `${SPACE_HOME}/.openchamber-space`;
|
|
61
|
+
export const SPACE_TOKEN_PATH = `${SPACE_TOKEN_DIRECTORY}/token`;
|
|
62
|
+
|
|
63
|
+
// The gatekeeper, the space's only way out. It sits on the space's internal network under this
|
|
64
|
+
// name and on the space's own outer network, and nothing else joins either of them.
|
|
65
|
+
export const GATEKEEPER_ALIAS = 'gatekeeper';
|
|
66
|
+
// The two listeners the space may see. They bind every interface of the gatekeeper, because
|
|
67
|
+
// the space's network is the point of them.
|
|
68
|
+
const GATEKEEPER_BIND_HOST = '0.0.0.0';
|
|
69
|
+
export const GATEKEEPER_CORRIDOR_PORT = 3128;
|
|
70
|
+
export const GATEKEEPER_WINDOW_PORT = 8080;
|
|
71
|
+
// The control channel. It binds the gatekeeper's own loopback, so only an `exec` from the host
|
|
72
|
+
// reaches it. Measured on Docker 29.2.1: a listener on 127.0.0.1 in a container is refused from
|
|
73
|
+
// another container on the same network, while the same listener on 0.0.0.0 answers.
|
|
74
|
+
export const GATEKEEPER_CONTROL_HOST = '127.0.0.1';
|
|
75
|
+
export const GATEKEEPER_CONTROL_PORT = 9099;
|
|
76
|
+
|
|
77
|
+
// Where the space sends its traffic. The window's own URL, `http://gatekeeper:8080/model/<grant>`,
|
|
78
|
+
// belongs to whoever writes a provider configuration, which no stage does yet.
|
|
79
|
+
const GATEKEEPER_CORRIDOR_URL = `http://${GATEKEEPER_ALIAS}:${GATEKEEPER_CORRIDOR_PORT}`;
|
|
80
|
+
|
|
81
|
+
// The gatekeeper's program arrives over `exec` on stdin, into the container's tmpfs. It is far
|
|
82
|
+
// larger than the tools filler, and a `node -e` argument of that size was never tried on Windows.
|
|
83
|
+
export const GATEKEEPER_PROGRAM_DIRECTORY = '/tmp/openchamber-gatekeeper';
|
|
84
|
+
export const GATEKEEPER_PROGRAM_PATH = `${GATEKEEPER_PROGRAM_DIRECTORY}/gatekeeper.cjs`;
|
|
85
|
+
|
|
86
|
+
const IMAGE_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
87
|
+
|
|
88
|
+
// The programs of the base image that the host runs inside a container, by absolute path,
|
|
89
|
+
// so that no PATH decides what the host runs.
|
|
90
|
+
// Verified in the pinned image with `command -v`: `/bin` is a link to `/usr/bin`.
|
|
91
|
+
export const IMAGE_SH = '/bin/sh';
|
|
92
|
+
export const IMAGE_CAT = '/bin/cat';
|
|
93
|
+
export const IMAGE_CHOWN = '/bin/chown';
|
|
94
|
+
export const IMAGE_CURL = '/usr/bin/curl';
|
|
95
|
+
export const IMAGE_NODE = '/usr/local/bin/node';
|
|
96
|
+
const IMAGE_SLEEP = '/bin/sleep';
|
|
97
|
+
// Code in: the receiving side of a push runs as `timeout -s KILL <seconds> git receive-pack <path>`.
|
|
98
|
+
// The image's git is 2.39.5, and it has no `pkill`.
|
|
99
|
+
export const IMAGE_GIT = '/usr/bin/git';
|
|
100
|
+
export const IMAGE_TIMEOUT = '/usr/bin/timeout';
|
|
101
|
+
|
|
102
|
+
/** First line of every fixed script the host runs inside: its commands come from the image only. */
|
|
103
|
+
export const IMAGE_ONLY_PATH = `PATH=${IMAGE_PATH};`;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The environment of a space. The password of the server inside and
|
|
107
|
+
* OPENCODE_AUTH_CONTENT never go here: container env is readable through `inspect`.
|
|
108
|
+
*/
|
|
109
|
+
export const SPACE_ENVIRONMENT = Object.freeze({
|
|
110
|
+
HOME: SPACE_HOME,
|
|
111
|
+
// The tools come last. The image has no `openchamber` and no `opencode`, so both are still found,
|
|
112
|
+
// and a transitive npm package that ships a bin named `node` or `sh` never shadows the image's.
|
|
113
|
+
PATH: `${IMAGE_PATH}:${TOOLS_BIN_PATH}`,
|
|
114
|
+
// The corridor, in both spellings. curl 7.88.1 in this image ignores an uppercase HTTP_PROXY on
|
|
115
|
+
// purpose, the httpoxy protection, and honours the lowercase one. Without the lowercase spelling
|
|
116
|
+
// plain-HTTP traffic from curl and from everything that uses libcurl never reaches the corridor.
|
|
117
|
+
HTTPS_PROXY: GATEKEEPER_CORRIDOR_URL,
|
|
118
|
+
https_proxy: GATEKEEPER_CORRIDOR_URL,
|
|
119
|
+
HTTP_PROXY: GATEKEEPER_CORRIDOR_URL,
|
|
120
|
+
http_proxy: GATEKEEPER_CORRIDOR_URL,
|
|
121
|
+
// Window traffic skips the corridor, and so does the space's own loopback. Measured: with
|
|
122
|
+
// http_proxy set, curl sends even http://127.0.0.1 to the corridor, and Node with
|
|
123
|
+
// NODE_USE_ENV_PROXY=1 proxies loopback too. Both spellings, because curl reads both.
|
|
124
|
+
NO_PROXY: `${GATEKEEPER_ALIAS},localhost,127.0.0.1`,
|
|
125
|
+
no_proxy: `${GATEKEEPER_ALIAS},localhost,127.0.0.1`,
|
|
126
|
+
// Node 22 ignores proxy variables without this. Measured: EAI_AGAIN for every fetch.
|
|
127
|
+
NODE_USE_ENV_PROXY: '1',
|
|
128
|
+
// OpenCode's catalog and update downloads would only spend corridor attempts.
|
|
129
|
+
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
|
130
|
+
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
|
131
|
+
// The server inside a space never hosts the relay passively. It has a way out now, and a
|
|
132
|
+
// space must not become the machine that paired devices land on.
|
|
133
|
+
OPENCHAMBER_RELAY_HOST: 'off',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Waits for the token file, takes it as the server password, and becomes the server.
|
|
137
|
+
// The password reaches the server through the environment of this one process, never through the container's.
|
|
138
|
+
const SERVER_SCRIPT = [
|
|
139
|
+
`while [ ! -s ${SPACE_TOKEN_PATH} ]; do ${IMAGE_SLEEP} 0.2; done;`,
|
|
140
|
+
`OPENCHAMBER_UI_PASSWORD="$(${IMAGE_CAT} ${SPACE_TOKEN_PATH})";`,
|
|
141
|
+
'export OPENCHAMBER_UI_PASSWORD;',
|
|
142
|
+
`exec openchamber serve --foreground --api-only --host ${SPACE_SERVER_HOST} --port ${SPACE_SERVER_PORT}`,
|
|
143
|
+
].join(' ');
|
|
144
|
+
|
|
145
|
+
/** The command of a space container. A fixed script, nothing in it varies per space. */
|
|
146
|
+
export const SPACE_SERVER_COMMAND = Object.freeze([IMAGE_SH, '-c', SERVER_SCRIPT]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The environment of a gatekeeper. It holds no secret and never will: grants arrive on the
|
|
150
|
+
* stdin of an `exec` and live in the program's memory. HOME points into the tmpfs, because the
|
|
151
|
+
* root filesystem is read-only and a program that resolves HOME must land somewhere writable.
|
|
152
|
+
*/
|
|
153
|
+
export const GATEKEEPER_ENVIRONMENT = Object.freeze({ HOME: '/tmp' });
|
|
154
|
+
|
|
155
|
+
// How long the window waits for an upstream that has gone quiet. The same as the corridor's
|
|
156
|
+
// tunnel idle limit, and for the same reason: a model answer streams, so silence is what counts.
|
|
157
|
+
const GATEKEEPER_WINDOW_DEADLINE_MS = 300_000;
|
|
158
|
+
|
|
159
|
+
// How many connections each listener holds at once. The corridor's is twice the tunnel cap, so
|
|
160
|
+
// every handshake a working space makes has room; the window serves one request per connection
|
|
161
|
+
// and holds a socket out for each; only the host talks to the control channel.
|
|
162
|
+
const GATEKEEPER_CORRIDOR_CONNECTIONS = 128;
|
|
163
|
+
const GATEKEEPER_WINDOW_CONNECTIONS = 64;
|
|
164
|
+
const GATEKEEPER_CONTROL_CONNECTIONS = 8;
|
|
165
|
+
|
|
166
|
+
// The same shape as the space's wait for its token: the container comes up, waits for the file
|
|
167
|
+
// the host sends over `exec`, and becomes the program. The ports, the deadline and the caps are
|
|
168
|
+
// arguments, so the one program serves the container and the tests without a switch that weakens
|
|
169
|
+
// it. These numbers are the production ones and the hardening test asserts this whole command.
|
|
170
|
+
const GATEKEEPER_SCRIPT = [
|
|
171
|
+
`while [ ! -s ${GATEKEEPER_PROGRAM_PATH} ]; do ${IMAGE_SLEEP} 0.2; done;`,
|
|
172
|
+
`exec ${IMAGE_NODE} ${GATEKEEPER_PROGRAM_PATH}`,
|
|
173
|
+
GATEKEEPER_BIND_HOST,
|
|
174
|
+
String(GATEKEEPER_CORRIDOR_PORT),
|
|
175
|
+
String(GATEKEEPER_WINDOW_PORT),
|
|
176
|
+
String(GATEKEEPER_CONTROL_PORT),
|
|
177
|
+
String(GATEKEEPER_WINDOW_DEADLINE_MS),
|
|
178
|
+
String(GATEKEEPER_CORRIDOR_CONNECTIONS),
|
|
179
|
+
String(GATEKEEPER_WINDOW_CONNECTIONS),
|
|
180
|
+
String(GATEKEEPER_CONTROL_CONNECTIONS),
|
|
181
|
+
].join(' ');
|
|
182
|
+
|
|
183
|
+
/** The command of a gatekeeper container. Fixed, and the same for every space. */
|
|
184
|
+
export const GATEKEEPER_COMMAND = Object.freeze([IMAGE_SH, '-c', GATEKEEPER_SCRIPT]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { projectFolderName, spaceHistoryPath, spaceProjectPath } from './layout.js';
|
|
4
|
+
|
|
5
|
+
describe('projectFolderName', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
['/home/me/openchamber', 'openchamber'],
|
|
8
|
+
['/home/me/my app/', 'my-app'],
|
|
9
|
+
['C:\\Users\\me\\Projects\\web_site.v2', 'web_site.v2'],
|
|
10
|
+
['/home/me/.dotfiles', 'dotfiles'],
|
|
11
|
+
['/home/me/-rf', 'rf'],
|
|
12
|
+
['/home/me/bait repo ї', 'bait-repo-'],
|
|
13
|
+
['/home/me/$(touch x);`id`', 'touch-x-id-'],
|
|
14
|
+
[`/home/me/${'a'.repeat(100)}`, 'a'.repeat(64)],
|
|
15
|
+
])('names %s as %s', (directory, name) => {
|
|
16
|
+
expect(projectFolderName(directory)).toBe(name);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// The plugin link lives at /spaces/<id>/node_modules, and a name with nothing readable left says nothing.
|
|
20
|
+
it.each(['/home/me/node_modules', '/home/me/NODE_MODULES', '/home/me/юнікод', '/home/me/...', '/', ''])('falls back to a fixed name for %j', (directory) => {
|
|
21
|
+
expect(projectFolderName(directory)).toBe('project');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('puts the project and the history side repository in the space, where they cannot collide', () => {
|
|
25
|
+
expect(spaceProjectPath('a1b2c3d4e5f6', '/home/me/openchamber')).toBe('/spaces/a1b2c3d4e5f6/openchamber');
|
|
26
|
+
expect(spaceHistoryPath('a1b2c3d4e5f6')).toBe('/spaces/a1b2c3d4e5f6/.openchamber-history.git');
|
|
27
|
+
expect(spaceProjectPath('a1b2c3d4e5f6', '/home/me/.openchamber-history.git')).toBe('/spaces/a1b2c3d4e5f6/openchamber-history.git');
|
|
28
|
+
expect(() => spaceProjectPath('../etc', '/home/me/openchamber')).toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|