@vantran-se/openchamber-web 1.23.1 → 1.24.3
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/bin/cli.test.js +29 -6
- package/bin/lib/cli-http.js +5 -1
- package/bin/lib/commands-status.js +5 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BOpqXpFU.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
- package/dist/assets/DiffView-COVQhWGd.js +4 -0
- package/dist/assets/FilesView-B1V5JxFX.js +13 -0
- package/dist/assets/GitView-CDOJy5Ay.js +6 -0
- package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
- package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-D0C9nU7K.js} +1 -1
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/LinearIssuesView-D7FKg1XJ.js +1 -0
- package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
- package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
- package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DnEhjXcC.js} +1 -1
- package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
- package/dist/assets/PlanView-LW2z_EAP.js +5 -0
- package/dist/assets/PluginPane-B4tKno2h.js +3 -0
- package/dist/assets/SettingsView-DEaMulN9.js +29 -0
- package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-FLDJXOgQ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
- package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-0pyr_mHY.js} +3 -3
- package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-yNj4nOP5.js} +2 -2
- package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
- package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DxTBMuu-.js} +1 -1
- package/dist/assets/de-DgxgFyGG.js +10 -0
- package/dist/assets/es-BFsZj5F_.js +16 -0
- package/dist/assets/fr-jdZ5KKQi.js +14 -0
- package/dist/assets/{index-eo1-D_7z.js → index-CKHjPKbq.js} +1 -1
- package/dist/assets/index-Cbci4i1W.js +40 -0
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-D7FkBMP6.js} +1 -1
- package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-CL2vsrIZ.js} +1 -1
- package/dist/assets/ja-C5kKrhO4.js +16 -0
- package/dist/assets/ko-DXDTT_0O.js +16 -0
- package/dist/assets/main-CZALtVek.js +896 -0
- package/dist/assets/main-CgXMBNvG.js +2 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
- package/dist/assets/miniChat-CwTmDixi.js +2 -0
- package/dist/assets/mobile-CLf2vCtO.js +2 -0
- package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-Bsba8KFp.js} +3 -3
- package/dist/assets/pl-D-7x4Y8Q.js +16 -0
- package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
- package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
- package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
- package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-DzjbLd8P.js} +1 -1
- package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
- package/dist/assets/tr-Dr-oJ22L.js +7 -0
- package/dist/assets/uk-9Nx83UcI.js +16 -0
- package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
- package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
- package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
- package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
- package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
- package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
- package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
- package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
- package/dist/assets/{web-components-B_Z64So5.js → web-components-B8IcVQAA.js} +2 -2
- package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
- package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
- package/dist/index.html +6 -4
- package/dist/mini-chat.html +6 -4
- package/dist/mobile.html +6 -4
- package/package.json +12 -9
- package/server/built-in-extensions/registry.json +4 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +44 -10
- package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
- package/server/lib/agent-tool/runtime.js +62 -15
- package/server/lib/agent-tool/runtime.test.js +88 -2
- package/server/lib/changelog/update-notes.js +2 -1
- package/server/lib/changelog/update-notes.test.js +19 -0
- package/server/lib/event-stream/DOCUMENTATION.md +23 -0
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +292 -0
- package/server/lib/event-stream/global-hub.js +53 -23
- package/server/lib/event-stream/global-hub.test.js +160 -0
- package/server/lib/event-stream/global-ws-bridge.js +1 -0
- package/server/lib/event-stream/index.js +4 -0
- package/server/lib/fs/DOCUMENTATION.md +10 -1
- package/server/lib/fs/git-process.test.js +77 -0
- package/server/lib/fs/routes.js +58 -5
- package/server/lib/fs/routes.test.js +116 -0
- package/server/lib/fs/search.js +2 -1
- package/server/lib/git/DOCUMENTATION.md +20 -5
- package/server/lib/git/routes.js +16 -3
- package/server/lib/git/serial-refresh.js +93 -0
- package/server/lib/git/serial-refresh.test.js +112 -0
- package/server/lib/git/service.js +429 -70
- package/server/lib/git/service.test.js +418 -0
- package/server/lib/github/pr-status.js +5 -5
- package/server/lib/github/pr-status.test.js +1 -1
- package/server/lib/github/routes.js +4 -4
- package/server/lib/guests/DOCUMENTATION.md +68 -0
- package/server/lib/guests/auth-store.js +184 -0
- package/server/lib/guests/auth-store.test.js +112 -0
- package/server/lib/guests/background.test.js +83 -0
- package/server/lib/guests/builtins.js +34 -0
- package/server/lib/guests/builtins.test.js +174 -0
- package/server/lib/guests/catalog.js +477 -0
- package/server/lib/guests/catalog.test.js +390 -0
- package/server/lib/guests/clone.js +297 -0
- package/server/lib/guests/extract-zip.js +136 -0
- package/server/lib/guests/extract-zip.test.js +109 -0
- package/server/lib/guests/files.js +308 -0
- package/server/lib/guests/files.test.js +262 -0
- package/server/lib/guests/grant-scope.js +81 -0
- package/server/lib/guests/grant-scope.test.js +75 -0
- package/server/lib/guests/host-session.js +73 -0
- package/server/lib/guests/host-session.test.js +30 -0
- package/server/lib/guests/html-styles.js +6 -0
- package/server/lib/guests/html-styles.test.js +45 -0
- package/server/lib/guests/html-tokens.js +73 -0
- package/server/lib/guests/html-tokens.test.js +53 -0
- package/server/lib/guests/install.js +406 -0
- package/server/lib/guests/install.test.js +338 -0
- package/server/lib/guests/oauth.js +501 -0
- package/server/lib/guests/oauth.test.js +481 -0
- package/server/lib/guests/persist.js +336 -0
- package/server/lib/guests/persist.test.js +288 -0
- package/server/lib/guests/request.js +112 -0
- package/server/lib/guests/request.test.js +206 -0
- package/server/lib/guests/routes.js +838 -0
- package/server/lib/guests/service.js +538 -0
- package/server/lib/guests/service.test.js +277 -0
- package/server/lib/guests/sockets.js +129 -0
- package/server/lib/guests/sockets.test.js +110 -0
- package/server/lib/guests/ssh-install.test.js +188 -0
- package/server/lib/guests/storage.js +68 -0
- package/server/lib/guests/storage.test.js +59 -0
- package/server/lib/guests/updates.js +294 -0
- package/server/lib/guests/updates.test.js +334 -0
- package/server/lib/guests/upload.js +50 -0
- package/server/lib/guests/upload.test.js +131 -0
- package/server/lib/linear/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/DOCUMENTATION.md +10 -1
- package/server/lib/message-queue/runtime.js +21 -1
- package/server/lib/message-queue/runtime.test.js +52 -5
- package/server/lib/notifications/routes.js +5 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
- package/server/lib/openchamber-sessions/routes.js +62 -29
- package/server/lib/openchamber-sessions/routes.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +55 -1
- package/server/lib/opencode/bootstrap-runtime.js +1 -0
- package/server/lib/opencode/core-routes.js +39 -1
- package/server/lib/opencode/feature-routes-runtime.js +15 -0
- package/server/lib/opencode/lifecycle-process.test.js +84 -0
- package/server/lib/opencode/lifecycle.js +54 -67
- package/server/lib/opencode/openchamber-routes.js +72 -14
- package/server/lib/opencode/openchamber-routes.test.js +71 -3
- package/server/lib/opencode/session-runtime.js +68 -0
- package/server/lib/opencode/session-runtime.test.js +43 -0
- package/server/lib/opencode/settings-helpers.js +8 -0
- package/server/lib/opencode/settings-helpers.test.js +13 -1
- package/server/lib/opencode/settings-normalization-runtime.js +2 -0
- package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
- package/server/lib/opencode/settings-registry.json +15 -0
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/shutdown-runtime.test.js +24 -0
- package/server/lib/opencode/theme-archive.js +53 -0
- package/server/lib/opencode/theme-catalog.js +171 -0
- package/server/lib/opencode/theme-catalog.test.js +125 -0
- package/server/lib/opencode/theme-import.test.js +144 -0
- package/server/lib/opencode/theme-runtime.js +100 -28
- package/server/lib/opencode/theme-runtime.test.js +15 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
- package/server/lib/permission-auto-accept/runtime.js +13 -0
- package/server/lib/permission-auto-accept/runtime.test.js +37 -1
- package/server/lib/projects/project-config.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +0 -1
- package/server/lib/quota/providers/index.js +0 -7
- package/server/lib/realtime-proxy-terminal.test.js +150 -0
- package/server/lib/realtime-proxy.js +2 -0
- package/server/lib/routing/DOCUMENTATION.md +83 -0
- package/server/lib/routing/defaults.js +98 -0
- package/server/lib/routing/feature-flag.js +11 -0
- package/server/lib/routing/history.js +34 -0
- package/server/lib/routing/jev.js +98 -0
- package/server/lib/routing/routes.http.test.js +100 -0
- package/server/lib/routing/routes.js +81 -0
- package/server/lib/routing/runtime.js +217 -0
- package/server/lib/routing/runtime.test.js +188 -0
- package/server/lib/routing/store.js +249 -0
- package/server/lib/routing/store.test.js +96 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -2
- package/server/lib/scheduled-tasks/runtime.test.js +14 -0
- package/server/lib/security/request-security.js +9 -5
- package/server/lib/security/request-security.test.js +37 -0
- package/server/lib/small-model/DOCUMENTATION.md +14 -2
- package/server/lib/small-model/call.js +37 -6
- package/server/lib/small-model/call.test.js +182 -0
- package/server/lib/terminal/DOCUMENTATION.md +23 -2
- package/server/lib/terminal/runtime.js +27 -5
- package/server/lib/terminal/runtime.test.js +67 -0
- package/server/lib/terminal/shutdown.js +106 -0
- package/server/lib/terminal/shutdown.test.js +84 -0
- package/server/lib/tts/service.test.js +78 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
- package/server/lib/ui-auth/session-cookie.js +51 -0
- package/server/lib/ui-auth/session-cookie.test.js +46 -0
- package/server/lib/ui-auth/ui-auth.js +56 -17
- package/server/lib/ui-auth/ui-auth.test.js +96 -0
- package/dist/assets/DiffView-CyLmb-wF.js +0 -4
- package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
- package/dist/assets/FilesView-WtJB0y_l.js +0 -13
- package/dist/assets/GitView-DkNrV0BK.js +0 -6
- package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
- package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
- package/dist/assets/PlanView-CAFziKH5.js +0 -5
- package/dist/assets/SettingsView-BRittavZ.js +0 -28
- package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
- package/dist/assets/de-c7CYLCAW.js +0 -10
- package/dist/assets/es-BKAVPQmQ.js +0 -16
- package/dist/assets/fr-Bj1glqfV.js +0 -14
- package/dist/assets/index-BYFyOu_D.css +0 -1
- package/dist/assets/index-BfwRClDe.js +0 -40
- package/dist/assets/ja-5jwSIbK0.js +0 -16
- package/dist/assets/ko-BaDbg-Eb.js +0 -16
- package/dist/assets/main-CGG2_uHq.js +0 -2
- package/dist/assets/main-CcrSaL4J.js +0 -930
- package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
- package/dist/assets/miniChat-DEaAhP4b.js +0 -2
- package/dist/assets/mobile-BC3rsvv0.js +0 -2
- package/dist/assets/pl-BFKVBDFu.js +0 -16
- package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
- package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
- package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
- package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
- package/dist/assets/tr-DYXPoEDG.js +0 -7
- package/dist/assets/uk-CwAoloR8.js +0 -16
- package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
- package/dist/assets/useGitComparison-CpxioduU.js +0 -1
- package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
- package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
- package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
- package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
- package/dist/assets/zh-TW-1HNK7shh.js +0 -16
- package/server/lib/quota/providers/crof.js +0 -96
- package/server/lib/quota/providers/crof.test.js +0 -98
- /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { guestAssetContentType, inspectGuestPackage, listInstalledGuests, resolveGuestAssetPath, resolveGuestServedFile, toPublicGuest } from './catalog.js';
|
|
6
|
+
import { setCapabilityGrants, writeExtensionPaths } from './persist.js';
|
|
7
|
+
|
|
8
|
+
const writeBuiltGuest = async (root) => {
|
|
9
|
+
await fs.mkdir(path.join(root, 'panel'), { recursive: true });
|
|
10
|
+
await fs.writeFile(path.join(root, 'panel', 'index.html'), '<script src="./main.js"></script>');
|
|
11
|
+
await fs.writeFile(path.join(root, 'panel', 'main.js'), 'console.log("hello")');
|
|
12
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify({
|
|
13
|
+
name: '@openchamber/hello',
|
|
14
|
+
openchamber: {
|
|
15
|
+
apiVersion: 1,
|
|
16
|
+
contributes: {
|
|
17
|
+
panel: { id: 'hello', name: 'Hello', icon: 'window', entry: 'panel/index.html' },
|
|
18
|
+
attach: 'dialog',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('resolveGuestAssetPath', () => {
|
|
25
|
+
test('stays inside the package and rejects escapes', async () => {
|
|
26
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
27
|
+
await fs.writeFile(path.join(root, 'index.html'), '<html></html>');
|
|
28
|
+
await fs.mkdir(path.join(root, 'panel'));
|
|
29
|
+
await fs.writeFile(path.join(root, 'panel', 'index.html'), '<html></html>');
|
|
30
|
+
|
|
31
|
+
expect(await resolveGuestAssetPath(root, 'index.html')).toBe(await fs.realpath(path.join(root, 'index.html')));
|
|
32
|
+
expect(await resolveGuestAssetPath(root, 'panel/index.html')).toBe(await fs.realpath(path.join(root, 'panel', 'index.html')));
|
|
33
|
+
expect(await resolveGuestAssetPath(root, '../secret.html')).toBeNull();
|
|
34
|
+
expect(await resolveGuestAssetPath(root, '/etc/passwd')).toBeNull();
|
|
35
|
+
expect(await resolveGuestAssetPath(root, 'missing.html')).toBeNull();
|
|
36
|
+
|
|
37
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('guestAssetContentType', () => {
|
|
42
|
+
test('refuses unknown extensions', () => {
|
|
43
|
+
expect(guestAssetContentType('panel/index.html')).toContain('text/html');
|
|
44
|
+
expect(guestAssetContentType('icon.svg')).toBe('image/svg+xml');
|
|
45
|
+
expect(guestAssetContentType('notes.md')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('listInstalledGuests', () => {
|
|
50
|
+
test('does not auto-install the repo sample', async () => {
|
|
51
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
52
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
53
|
+
expect(await listInstalledGuests({ persistPath })).toEqual([]);
|
|
54
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('serves a path-installed guest', async () => {
|
|
58
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
59
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
60
|
+
const guestRoot = path.join(dir, 'hello');
|
|
61
|
+
await writeBuiltGuest(guestRoot);
|
|
62
|
+
await writeExtensionPaths([guestRoot], persistPath);
|
|
63
|
+
|
|
64
|
+
const guests = await listInstalledGuests({ persistPath });
|
|
65
|
+
const hello = guests.find((guest) => guest.id === 'hello');
|
|
66
|
+
expect(hello?.name).toBe('Hello');
|
|
67
|
+
expect(hello?.source).toBe('path');
|
|
68
|
+
expect(hello?.attach).toBe('dialog');
|
|
69
|
+
expect(hello).toBeTruthy();
|
|
70
|
+
if (!hello) {
|
|
71
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
expect(toPublicGuest(hello)).toEqual({
|
|
75
|
+
id: 'hello',
|
|
76
|
+
name: 'Hello',
|
|
77
|
+
icon: 'window',
|
|
78
|
+
entry: 'panel/index.html',
|
|
79
|
+
source: 'path',
|
|
80
|
+
path: hello.path,
|
|
81
|
+
attach: 'dialog',
|
|
82
|
+
enabled: true,
|
|
83
|
+
capabilities: { requested: [], granted: [] },
|
|
84
|
+
});
|
|
85
|
+
expect(toPublicGuest({
|
|
86
|
+
...hello,
|
|
87
|
+
integration: {
|
|
88
|
+
name: 'ClickUp',
|
|
89
|
+
description: 'Tasks',
|
|
90
|
+
oauth: {
|
|
91
|
+
authorizeUrl: 'https://app.clickup.com/api',
|
|
92
|
+
tokenUrl: 'https://api.clickup.com/api/v2/oauth/token',
|
|
93
|
+
apiOrigin: 'https://api.clickup.com',
|
|
94
|
+
},
|
|
95
|
+
settings: [{ id: 'list-id', label: 'List ID' }],
|
|
96
|
+
},
|
|
97
|
+
})).toEqual({
|
|
98
|
+
id: 'hello',
|
|
99
|
+
name: 'Hello',
|
|
100
|
+
icon: 'window',
|
|
101
|
+
entry: 'panel/index.html',
|
|
102
|
+
source: 'path',
|
|
103
|
+
path: hello.path,
|
|
104
|
+
attach: 'dialog',
|
|
105
|
+
enabled: true,
|
|
106
|
+
capabilities: { requested: ['network'], granted: [] },
|
|
107
|
+
integration: {
|
|
108
|
+
name: 'ClickUp',
|
|
109
|
+
description: 'Tasks',
|
|
110
|
+
apiOrigin: 'https://api.clickup.com',
|
|
111
|
+
auth: 'oauth',
|
|
112
|
+
settings: [{ id: 'list-id', label: 'List ID' }],
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const served = await resolveGuestServedFile(hello.packageRoot, 'panel/main.js');
|
|
116
|
+
expect(served?.contentType).toContain('javascript');
|
|
117
|
+
expect(await resolveGuestServedFile(hello.packageRoot, 'panel/main.ts')).toBeNull();
|
|
118
|
+
|
|
119
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('skips a missing folder and keeps the rest', async () => {
|
|
123
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
124
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
125
|
+
const guestRoot = path.join(dir, 'clone');
|
|
126
|
+
await fs.mkdir(path.join(guestRoot, 'panel'), { recursive: true });
|
|
127
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'index.html'), '<html></html>');
|
|
128
|
+
await fs.writeFile(path.join(guestRoot, 'package.json'), JSON.stringify({
|
|
129
|
+
name: '@openchamber/clone-hello',
|
|
130
|
+
openchamber: {
|
|
131
|
+
apiVersion: 1,
|
|
132
|
+
contributes: {
|
|
133
|
+
panel: { id: 'clone-hello', name: 'Clone', icon: 'window', entry: 'panel/index.html' },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
await writeExtensionPaths([path.join(dir, 'gone'), guestRoot], persistPath);
|
|
138
|
+
|
|
139
|
+
const guests = await listInstalledGuests({ persistPath });
|
|
140
|
+
expect(guests.some((guest) => guest.id === 'clone-hello' && guest.source === 'path')).toBe(true);
|
|
141
|
+
expect(guests.some((guest) => guest.path === path.join(dir, 'gone'))).toBe(false);
|
|
142
|
+
|
|
143
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('skips a package whose entry script was never built', async () => {
|
|
147
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
148
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
149
|
+
const guestRoot = path.join(dir, 'source-only');
|
|
150
|
+
await fs.mkdir(path.join(guestRoot, 'panel'), { recursive: true });
|
|
151
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'index.html'), '<script src="./main.js"></script>');
|
|
152
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'main.ts'), 'console.log(1)');
|
|
153
|
+
await fs.writeFile(path.join(guestRoot, 'package.json'), JSON.stringify({
|
|
154
|
+
name: '@openchamber/source-only',
|
|
155
|
+
openchamber: {
|
|
156
|
+
apiVersion: 1,
|
|
157
|
+
contributes: {
|
|
158
|
+
panel: { id: 'source-only', name: 'Source', icon: 'window', entry: 'panel/index.html' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
162
|
+
await writeExtensionPaths([guestRoot], persistPath);
|
|
163
|
+
|
|
164
|
+
expect(await listInstalledGuests({ persistPath })).toEqual([]);
|
|
165
|
+
|
|
166
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('checks a declared dialog entry like panel.entry and exposes it on the public row', async () => {
|
|
170
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
171
|
+
const guestRoot = path.join(dir, 'tasks');
|
|
172
|
+
const writeManifest = async () => fs.writeFile(path.join(guestRoot, 'package.json'), JSON.stringify({
|
|
173
|
+
name: '@openchamber/tasks',
|
|
174
|
+
version: '1.0.0',
|
|
175
|
+
openchamber: {
|
|
176
|
+
apiVersion: 1,
|
|
177
|
+
contributes: {
|
|
178
|
+
panel: { id: 'tasks', name: 'Tasks', icon: 'window', entry: 'panel/index.html' },
|
|
179
|
+
attach: { mode: 'dialog', entry: 'panel/attach.html' },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
await fs.mkdir(path.join(guestRoot, 'panel'), { recursive: true });
|
|
184
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'index.html'), '<script src="./main.js"></script>');
|
|
185
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'main.js'), 'console.log("main")');
|
|
186
|
+
await writeManifest();
|
|
187
|
+
|
|
188
|
+
expect(await inspectGuestPackage(guestRoot, { openchamberVersion: '1.0.0' })).toEqual({ ok: false, code: 'invalid-manifest' });
|
|
189
|
+
|
|
190
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'attach.html'), '<script src="./attach.js"></script>');
|
|
191
|
+
expect(await inspectGuestPackage(guestRoot, { openchamberVersion: '1.0.0' })).toEqual({ ok: false, code: 'missing-build' });
|
|
192
|
+
|
|
193
|
+
await fs.writeFile(path.join(guestRoot, 'panel', 'attach.js'), 'console.log("attach")');
|
|
194
|
+
const inspected = await inspectGuestPackage(guestRoot, { openchamberVersion: '1.0.0' });
|
|
195
|
+
expect(inspected.ok).toBe(true);
|
|
196
|
+
if (inspected.ok) {
|
|
197
|
+
expect(inspected.guest.attach).toBe('dialog');
|
|
198
|
+
expect(inspected.guest.attachEntry).toBe('panel/attach.html');
|
|
199
|
+
expect(toPublicGuest({ ...inspected.guest, source: 'path', path: guestRoot })).toMatchObject({
|
|
200
|
+
attach: 'dialog',
|
|
201
|
+
attachEntry: 'panel/attach.html',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('page-less packages', () => {
|
|
210
|
+
test('validates full-screen HTML and built scripts and publishes its title', async () => {
|
|
211
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-page-'));
|
|
212
|
+
try {
|
|
213
|
+
await fs.mkdir(path.join(dir, 'panel'));
|
|
214
|
+
await fs.writeFile(path.join(dir, 'panel/index.html'), '<p>Panel</p>');
|
|
215
|
+
await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', openchamber: {
|
|
216
|
+
apiVersion: 1, contributes: { panel: { id: 'board', name: 'Board', icon: 'window', entry: 'panel/index.html' }, page: { entry: 'panel/page.html', title: 'Tasks' } },
|
|
217
|
+
} }));
|
|
218
|
+
expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'invalid-manifest' });
|
|
219
|
+
await fs.writeFile(path.join(dir, 'panel/page.html'), '<script src="page.js"></script>');
|
|
220
|
+
expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'missing-build' });
|
|
221
|
+
await fs.writeFile(path.join(dir, 'panel/page.js'), 'console.log("page")');
|
|
222
|
+
const inspected = await inspectGuestPackage(dir);
|
|
223
|
+
expect(inspected.ok).toBe(true);
|
|
224
|
+
expect(toPublicGuest(inspected.guest)).toMatchObject({ pageEntry: 'panel/page.html', pageTitle: 'Tasks' });
|
|
225
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
226
|
+
});
|
|
227
|
+
test('installs a tools-only package without entry, omits entry from the row, and never serves it a frame', async () => {
|
|
228
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
229
|
+
const guestRoot = path.join(dir, 'tools-only');
|
|
230
|
+
await fs.mkdir(path.join(guestRoot, 'icons'), { recursive: true });
|
|
231
|
+
await fs.writeFile(path.join(guestRoot, 'icons', 'tool.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>');
|
|
232
|
+
await fs.writeFile(path.join(guestRoot, 'stray.html'), '<script src="./stray.js"></script>');
|
|
233
|
+
await fs.writeFile(path.join(guestRoot, 'stray.js'), 'console.log(1)');
|
|
234
|
+
await fs.writeFile(path.join(guestRoot, 'package.json'), JSON.stringify({
|
|
235
|
+
name: '@openchamber/tools-only',
|
|
236
|
+
version: '1.0.0',
|
|
237
|
+
openchamber: {
|
|
238
|
+
apiVersion: 1,
|
|
239
|
+
contributes: {
|
|
240
|
+
panel: { id: 'tools-only', name: 'Tools Only', icon: 'tools' },
|
|
241
|
+
tools: [{ match: 'mcp.*', icon: 'icons/tool.svg', output: 'json' }],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
const inspected = await inspectGuestPackage(guestRoot, { openchamberVersion: '1.0.0' });
|
|
247
|
+
expect(inspected).toEqual({
|
|
248
|
+
ok: true,
|
|
249
|
+
guest: {
|
|
250
|
+
id: 'tools-only',
|
|
251
|
+
name: 'Tools Only',
|
|
252
|
+
icon: 'tools',
|
|
253
|
+
packageRoot: guestRoot,
|
|
254
|
+
version: '1.0.0',
|
|
255
|
+
tools: [{ match: 'mcp.*', icon: 'icons/tool.svg', output: 'json' }],
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
if (!inspected.ok) {
|
|
259
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const row = toPublicGuest({ ...inspected.guest, source: 'path', path: guestRoot });
|
|
263
|
+
expect(row).not.toHaveProperty('entry');
|
|
264
|
+
expect(row.tools).toEqual([{ match: 'mcp.*', icon: 'icons/tool.svg', output: 'json' }]);
|
|
265
|
+
|
|
266
|
+
const hasRuntime = false;
|
|
267
|
+
expect((await resolveGuestServedFile(guestRoot, 'icons/tool.svg', { hasRuntime }))?.contentType).toBe('image/svg+xml');
|
|
268
|
+
expect(await resolveGuestServedFile(guestRoot, 'stray.html', { hasRuntime })).toBeNull();
|
|
269
|
+
expect(await resolveGuestServedFile(guestRoot, 'stray.js', { hasRuntime })).toBeNull();
|
|
270
|
+
expect((await resolveGuestServedFile(guestRoot, 'stray.html', { hasRuntime: true }))?.contentType).toContain('html');
|
|
271
|
+
|
|
272
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('refuses a page-less package that declares a page-only contribution', async () => {
|
|
276
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
277
|
+
const guestRoot = path.join(dir, 'no-entry');
|
|
278
|
+
await fs.mkdir(guestRoot, { recursive: true });
|
|
279
|
+
await fs.writeFile(path.join(guestRoot, 'package.json'), JSON.stringify({
|
|
280
|
+
name: '@openchamber/no-entry',
|
|
281
|
+
version: '1.0.0',
|
|
282
|
+
openchamber: {
|
|
283
|
+
apiVersion: 1,
|
|
284
|
+
contributes: {
|
|
285
|
+
panel: { id: 'no-entry', name: 'No Entry', icon: 'tools' },
|
|
286
|
+
attach: 'dialog',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
}));
|
|
290
|
+
expect(await inspectGuestPackage(guestRoot, { openchamberVersion: '1.0.0' })).toEqual({ ok: false, code: 'invalid-manifest' });
|
|
291
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('actions and commands on the public row', () => {
|
|
296
|
+
test('copies declared actions and commands and asks for conversation when a session action wants messages', async () => {
|
|
297
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
298
|
+
await writeBuiltGuest(root);
|
|
299
|
+
const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
300
|
+
pkg.version = '1.0.0';
|
|
301
|
+
pkg.openchamber.contributes.actions = [
|
|
302
|
+
{ id: 'create-task', label: 'Create task', where: 'message', roles: ['assistant'] },
|
|
303
|
+
{ id: 'summarize', label: 'Summarize', where: 'session', payload: ['messages'] },
|
|
304
|
+
];
|
|
305
|
+
pkg.openchamber.contributes.commands = [{ name: 'task', description: 'Attach a task' }];
|
|
306
|
+
pkg.openchamber.contributes.tools = [
|
|
307
|
+
{ match: 'mcp.tasks.*', name: 'Tasks', icon: 'checkbox-circle', title: '{input.id}', output: 'table', columns: ['id', 'title'] },
|
|
308
|
+
];
|
|
309
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(pkg));
|
|
310
|
+
|
|
311
|
+
const inspected = await inspectGuestPackage(root);
|
|
312
|
+
expect(inspected.ok).toBe(true);
|
|
313
|
+
if (inspected.ok) {
|
|
314
|
+
const row = toPublicGuest({ ...inspected.guest, source: 'path', path: root, capabilityGrants: [] });
|
|
315
|
+
expect(row.actions).toEqual(pkg.openchamber.contributes.actions);
|
|
316
|
+
expect(row.commands).toEqual(pkg.openchamber.contributes.commands);
|
|
317
|
+
expect(row.tools).toEqual(pkg.openchamber.contributes.tools);
|
|
318
|
+
expect(row.capabilities).toEqual({ requested: ['conversation'], granted: [] });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
pkg.openchamber.contributes.actions = [{ id: 'x', label: 'X', where: 'message', payload: ['messages'] }];
|
|
322
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(pkg));
|
|
323
|
+
expect(await inspectGuestPackage(root)).toMatchObject({ ok: false, code: 'invalid-manifest' });
|
|
324
|
+
|
|
325
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('grant scopes', () => {
|
|
330
|
+
const writeScopedGuest = async (root, filesystem) => {
|
|
331
|
+
await fs.mkdir(path.join(root, 'panel'), { recursive: true });
|
|
332
|
+
await fs.writeFile(path.join(root, 'panel', 'index.html'), '<script src="./main.js"></script>');
|
|
333
|
+
await fs.writeFile(path.join(root, 'panel', 'main.js'), 'console.log("hello")');
|
|
334
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify({
|
|
335
|
+
name: '@openchamber/scoped',
|
|
336
|
+
version: '1.0.0',
|
|
337
|
+
openchamber: {
|
|
338
|
+
apiVersion: 1,
|
|
339
|
+
contributes: {
|
|
340
|
+
panel: { id: 'scoped', name: 'Scoped', icon: 'window', entry: 'panel/index.html' },
|
|
341
|
+
capabilities: ['prompt'],
|
|
342
|
+
filesystem,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
}));
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
test('a widened filesystem list drops that grant until the user approves again', async () => {
|
|
349
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
350
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
351
|
+
const root = path.join(dir, 'scoped');
|
|
352
|
+
try {
|
|
353
|
+
await writeScopedGuest(root, ['~/notes/**']);
|
|
354
|
+
await writeExtensionPaths([root], persistPath);
|
|
355
|
+
await setCapabilityGrants('scoped', persistPath, ['prompt', 'filesystem'], { filesystem: ['~/notes/**'] });
|
|
356
|
+
const [approved] = await listInstalledGuests({ persistPath });
|
|
357
|
+
expect(approved.capabilityGrants).toEqual(['prompt', 'filesystem']);
|
|
358
|
+
|
|
359
|
+
await writeScopedGuest(root, ['~/**']);
|
|
360
|
+
// Any store write drops the 5s catalog cache, as an update or a reinstall would.
|
|
361
|
+
await writeExtensionPaths([root], persistPath);
|
|
362
|
+
const [widened] = await listInstalledGuests({ persistPath });
|
|
363
|
+
expect(widened.capabilityGrants).toEqual(['prompt']);
|
|
364
|
+
expect(toPublicGuest(widened).capabilities).toEqual({ requested: ['prompt', 'filesystem'], granted: ['prompt'] });
|
|
365
|
+
} finally {
|
|
366
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('catalog cache under a store write', () => {
|
|
372
|
+
test('a listing that started before a withdrawal is not cached as current', async () => {
|
|
373
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
|
|
374
|
+
const persistPath = path.join(dir, 'extensions.json');
|
|
375
|
+
const root = path.join(dir, 'hello');
|
|
376
|
+
try {
|
|
377
|
+
await writeBuiltGuest(root);
|
|
378
|
+
await writeExtensionPaths([root], persistPath);
|
|
379
|
+
await setCapabilityGrants('hello', persistPath, ['prompt'], null);
|
|
380
|
+
// Start a listing (it reads the old file) and withdraw while it runs.
|
|
381
|
+
const early = listInstalledGuests({ persistPath });
|
|
382
|
+
await setCapabilityGrants('hello', persistPath, [], null);
|
|
383
|
+
await early;
|
|
384
|
+
const [after] = await listInstalledGuests({ persistPath });
|
|
385
|
+
expect(after.capabilityGrants).toEqual([]);
|
|
386
|
+
} finally {
|
|
387
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CLONE_TIMEOUT_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
const PRIVATE_IPV4 = [
|
|
10
|
+
/^127\./, /^10\./, /^192\.168\./, /^169\.254\./, /^0\./,
|
|
11
|
+
/^172\.(1[6-9]|2\d|3[01])\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An install URL must name a public host. The server fetches it itself, so a
|
|
16
|
+
* loopback or LAN address would turn "install from URL" into a request against
|
|
17
|
+
* OpenChamber's own machine or network.
|
|
18
|
+
*/
|
|
19
|
+
export const isPublicHostname = (hostname) => {
|
|
20
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
21
|
+
if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const version = net.isIP(host);
|
|
25
|
+
if (version === 4) {
|
|
26
|
+
return !PRIVATE_IPV4.some((pattern) => pattern.test(host));
|
|
27
|
+
}
|
|
28
|
+
if (version === 6) {
|
|
29
|
+
return !(host === '::1' || host === '::' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd') || host.startsWith('::ffff:'));
|
|
30
|
+
}
|
|
31
|
+
return host.includes('.');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A public-looking hostname can still resolve to a private address (a DNS
|
|
36
|
+
* record the link's author controls). Every address it resolves to must be
|
|
37
|
+
* public; a name that does not resolve is refused too.
|
|
38
|
+
* @param {string} hostname
|
|
39
|
+
* @param {(hostname: string, options: { all: true }) => Promise<Array<{ address: string }>>} [lookup]
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The addresses a public hostname resolves to, or `null` when the name is
|
|
43
|
+
* not public, does not resolve, or any answer is private. Callers connect to
|
|
44
|
+
* exactly these addresses, so a second lookup by the HTTP client or by git
|
|
45
|
+
* cannot answer differently.
|
|
46
|
+
* @param {string} hostname
|
|
47
|
+
* @param {(hostname: string, options: { all: true }) => Promise<Array<{ address: string, family?: number }>>} [lookup]
|
|
48
|
+
* @returns {Promise<Array<{ address: string, family: 4 | 6 }> | null>}
|
|
49
|
+
*/
|
|
50
|
+
export const publicAddressesOf = async (hostname, lookup = (name, options) => dns.lookup(name, options)) => {
|
|
51
|
+
const host = hostname.replace(/^\[|\]$/g, '');
|
|
52
|
+
if (!isPublicHostname(host)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const literal = net.isIP(host);
|
|
56
|
+
if (literal) {
|
|
57
|
+
return [{ address: host, family: literal === 6 ? 6 : 4 }];
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const addresses = await lookup(host, { all: true });
|
|
61
|
+
if (addresses.length === 0 || !addresses.every((entry) => isPublicHostname(entry.address))) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return addresses.map((entry) => ({ address: entry.address, family: net.isIP(entry.address) === 6 ? 6 : 4 }));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `-c` options that keep a git network operation on the checked host: no
|
|
72
|
+
* redirects, and the connection pinned to the addresses just resolved
|
|
73
|
+
* (`http.curloptResolve`, git 2.30+; older builds ignore the key and keep
|
|
74
|
+
* the redirect rule). `null` when the URL is https but not public.
|
|
75
|
+
* @param {string} url
|
|
76
|
+
* @param {Parameters<typeof publicAddressesOf>[1]} [lookup]
|
|
77
|
+
* @returns {Promise<string[] | null>}
|
|
78
|
+
*/
|
|
79
|
+
export const gitNetworkArgs = async (url, lookup) => {
|
|
80
|
+
const hostname = httpsHostname(url);
|
|
81
|
+
if (hostname === null) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const addresses = await publicAddressesOf(hostname, lookup);
|
|
85
|
+
if (!addresses) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const port = new URL(url).port || '443';
|
|
89
|
+
return [
|
|
90
|
+
'-c', 'http.followRedirects=false',
|
|
91
|
+
'-c', `http.curloptResolve=${hostname}:${port}:${addresses.map((entry) => entry.address).join(',')}`,
|
|
92
|
+
];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isHttpsGitUrl = (value) => {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = new URL(value);
|
|
98
|
+
return parsed.protocol === 'https:' && parsed.username === '' && parsed.password === '' && isPublicHostname(parsed.hostname);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** SSH URLs and scp-style addresses, with no shell syntax in their components. */
|
|
105
|
+
const parseSshGitUrl = (value) => {
|
|
106
|
+
if (/[\s\\\0]/.test(value)) return null;
|
|
107
|
+
let hostname;
|
|
108
|
+
let username;
|
|
109
|
+
let repository;
|
|
110
|
+
let port;
|
|
111
|
+
if (value.slice(0, 6).toLowerCase() === 'ssh://') {
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(value);
|
|
114
|
+
if (url.password || url.search || url.hash) return null;
|
|
115
|
+
hostname = url.hostname;
|
|
116
|
+
username = url.username;
|
|
117
|
+
repository = url.pathname;
|
|
118
|
+
port = url.port;
|
|
119
|
+
if (port && (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)) return null;
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
} else {
|
|
122
|
+
const match = /^(?:([A-Za-z0-9_][A-Za-z0-9_.-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*):(.+)$/.exec(value);
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
[, username = '', hostname, repository] = match;
|
|
125
|
+
}
|
|
126
|
+
if (!/^[A-Za-z0-9.-]+$/.test(hostname) && !net.isIP(hostname.replace(/^\[|\]$/g, ''))) return null;
|
|
127
|
+
if (!isPublicHostname(hostname) || (username && !/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(username))) return null;
|
|
128
|
+
if (!/^\/?[A-Za-z0-9_.~][A-Za-z0-9_./~-]*$/.test(repository) || repository.split('/').includes('..')) return null;
|
|
129
|
+
return port ? { hostname, port } : { hostname };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const isHttpsZipUrl = (value) => {
|
|
133
|
+
if (!isHttpsGitUrl(value)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return new URL(value).pathname.toLowerCase().endsWith('.zip');
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// A branch or tag name git will take after `--branch`. No option-looking
|
|
144
|
+
// names, no `..`, and nothing git refuses in a ref.
|
|
145
|
+
const GIT_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
|
|
146
|
+
|
|
147
|
+
/** @param {string} value */
|
|
148
|
+
export const isGitRef = (value) => (
|
|
149
|
+
GIT_REF_PATTERN.test(value)
|
|
150
|
+
&& !value.includes('..')
|
|
151
|
+
&& !value.endsWith('/')
|
|
152
|
+
&& !value.endsWith('.lock')
|
|
153
|
+
&& !value.includes('//')
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `https://host/org/panel.git#v1.2.0` → `{ url, ref }`. The fragment pins a
|
|
158
|
+
* branch or tag; without it the clone follows the remote default branch. A
|
|
159
|
+
* fragment that is not a usable ref, or a URL that is not public HTTPS/SSH, is
|
|
160
|
+
* `null` (the install route answers `invalid-url`).
|
|
161
|
+
* @param {string} value
|
|
162
|
+
*/
|
|
163
|
+
export const parseGitInstallUrl = (value) => {
|
|
164
|
+
const hashAt = value.indexOf('#');
|
|
165
|
+
const rawUrl = hashAt === -1 ? value : value.slice(0, hashAt);
|
|
166
|
+
const url = rawUrl.replace(/^ssh:\/\//i, 'ssh://');
|
|
167
|
+
const ref = hashAt === -1 ? '' : value.slice(hashAt + 1);
|
|
168
|
+
if (!isHttpsGitUrl(url) && !parseSshGitUrl(url)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (hashAt !== -1 && !isGitRef(ref)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return ref ? { url, ref } : { url };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run one git command without a terminal. Resolves `{ ok: true, stdout }` on
|
|
179
|
+
* exit 0 and `{ ok: false }` on a non-zero exit, a spawn error, or the
|
|
180
|
+
* timeout (the child is killed). Never rejects. `stdout` is captured only
|
|
181
|
+
* when `capture` is set so a clone's progress is not buffered.
|
|
182
|
+
*/
|
|
183
|
+
export const runGit = (args, { gitBinary = 'git', cwd, timeoutMs = CLONE_TIMEOUT_MS, capture = false, env = {} } = {}) => (
|
|
184
|
+
new Promise((resolve) => {
|
|
185
|
+
let child;
|
|
186
|
+
try {
|
|
187
|
+
child = spawn(
|
|
188
|
+
gitBinary,
|
|
189
|
+
args,
|
|
190
|
+
{
|
|
191
|
+
cwd,
|
|
192
|
+
windowsHide: true,
|
|
193
|
+
env: {
|
|
194
|
+
...process.env,
|
|
195
|
+
...env,
|
|
196
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
197
|
+
GIT_ASKPASS: 'echo',
|
|
198
|
+
},
|
|
199
|
+
stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'],
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
} catch {
|
|
203
|
+
resolve({ ok: false });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
/** @type {Buffer[]} */
|
|
207
|
+
const chunks = [];
|
|
208
|
+
if (capture && child.stdout) {
|
|
209
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
210
|
+
}
|
|
211
|
+
let settled = false;
|
|
212
|
+
const finish = (result) => {
|
|
213
|
+
if (settled) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
settled = true;
|
|
217
|
+
resolve(result);
|
|
218
|
+
};
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
child.kill('SIGKILL');
|
|
221
|
+
finish({ ok: false });
|
|
222
|
+
}, timeoutMs);
|
|
223
|
+
child.on('error', () => {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
finish({ ok: false });
|
|
226
|
+
});
|
|
227
|
+
child.on('close', (exit) => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
finish(exit === 0 ? { ok: true, stdout: Buffer.concat(chunks).toString('utf8') } : { ok: false });
|
|
230
|
+
});
|
|
231
|
+
})
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Clone `source` into `dest`. Production uses HTTPS/SSH; tests may use a local repo path.
|
|
236
|
+
* `gitBinary` comes from the host's git resolver: on Windows and in the packaged desktop app a bare
|
|
237
|
+
* `git` is often not on PATH. `ref` pins a branch or tag (`--branch`); a shallow clone of a tag
|
|
238
|
+
* works the same way as of a branch.
|
|
239
|
+
*/
|
|
240
|
+
/** @returns {string | null} the hostname when `value` is an https URL */
|
|
241
|
+
const httpsHostname = (value) => {
|
|
242
|
+
try {
|
|
243
|
+
const parsed = new URL(value);
|
|
244
|
+
return parsed.protocol === 'https:' ? parsed.hostname : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const quoteSshPath = (value) => {
|
|
251
|
+
const expanded = value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
|
|
252
|
+
const normalized = process.platform === 'win32' ? expanded.replace(/\\/g, '/') : expanded;
|
|
253
|
+
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Resolve identity on the server for every clone/fetch, so key rotation takes effect. */
|
|
257
|
+
export const prepareGuestGitNetwork = async (source, { gitIdentityId, lookup } = {}) => {
|
|
258
|
+
let sshCommand = process.env.GIT_SSH_COMMAND?.trim() || '';
|
|
259
|
+
if (gitIdentityId && gitIdentityId !== 'global') {
|
|
260
|
+
const { getProfile } = await import('../git/identity-storage.js');
|
|
261
|
+
const profile = getProfile(gitIdentityId);
|
|
262
|
+
if (!profile) return null;
|
|
263
|
+
if (profile.sshKey) sshCommand = `ssh -i ${quoteSshPath(profile.sshKey)} -o IdentitiesOnly=yes`;
|
|
264
|
+
}
|
|
265
|
+
const ssh = parseSshGitUrl(source);
|
|
266
|
+
if (ssh) {
|
|
267
|
+
const addresses = await publicAddressesOf(ssh.hostname, lookup);
|
|
268
|
+
if (!addresses) return null;
|
|
269
|
+
if (!sshCommand) {
|
|
270
|
+
const { getGlobalIdentity } = await import('../git/index.js');
|
|
271
|
+
const globalIdentity = await getGlobalIdentity();
|
|
272
|
+
sshCommand = globalIdentity.sshCommand || (process.env.GIT_SSH ? quoteSshPath(process.env.GIT_SSH) : 'ssh');
|
|
273
|
+
}
|
|
274
|
+
sshCommand += ` -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o Hostname=${addresses[0].address} -o HostKeyAlias=${ssh.hostname.replace(/^\[|\]$/g, '')} -o ProxyCommand=none -o ProxyJump=none`;
|
|
275
|
+
return { args: [], env: { GIT_SSH_COMMAND: sshCommand, GIT_SSH_VARIANT: 'ssh' } };
|
|
276
|
+
}
|
|
277
|
+
const args = await gitNetworkArgs(source, lookup);
|
|
278
|
+
if (!args) return null;
|
|
279
|
+
return sshCommand ? { args, env: { GIT_SSH_COMMAND: sshCommand } } : { args };
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export const cloneGitRepository = async (source, dest, { gitBinary = 'git', timeoutMs = CLONE_TIMEOUT_MS, ref, lookup, gitIdentityId } = {}) => {
|
|
283
|
+
if (ref !== undefined && !isGitRef(ref)) {
|
|
284
|
+
return { ok: false, code: 'clone-failed' };
|
|
285
|
+
}
|
|
286
|
+
const network = await prepareGuestGitNetwork(source, { lookup, gitIdentityId }).catch(() => null);
|
|
287
|
+
if (!network) {
|
|
288
|
+
return { ok: false, code: 'clone-failed' };
|
|
289
|
+
}
|
|
290
|
+
const args = [...network.args, 'clone', '--depth', '1'];
|
|
291
|
+
if (ref) {
|
|
292
|
+
args.push('--branch', ref);
|
|
293
|
+
}
|
|
294
|
+
args.push('--', source, dest);
|
|
295
|
+
const result = await runGit(args, { gitBinary, timeoutMs, env: network.env });
|
|
296
|
+
return result.ok ? { ok: true } : { ok: false, code: 'clone-failed' };
|
|
297
|
+
};
|