@vantran-se/openchamber-web 1.23.1 → 1.24.4
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-B4a0JkUz.js} +1 -1
- package/dist/assets/DiffView-B2CD9wC8.js +4 -0
- package/dist/assets/FilesView-C4vMFclc.js +13 -0
- package/dist/assets/GitView-Byu2DY7s.js +6 -0
- package/dist/assets/GuestAttachDialog-B808E0-d.js +1 -0
- package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-VW_7czo3.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-EzGNCGrd.js +1 -0
- package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-B2YUDFaB.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +12 -0
- package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-CQHBSTEi.js} +1 -1
- package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DCjGhlYV.js} +1 -1
- package/dist/assets/PierreDiffViewer-CEDZNkU2.js +210 -0
- package/dist/assets/PlanView-D3x-pTkZ.js +5 -0
- package/dist/assets/PluginPane-DEcgDQ9h.js +3 -0
- package/dist/assets/SettingsView-CSinuH-1.js +29 -0
- package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-B5CHoJFZ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +1 -0
- package/dist/assets/ToolOutputDialog-tLNWmfcW.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CflzmK-x.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-NU0FHFhT.js} +3 -3
- package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-BZyjhqHv.js} +2 -2
- package/dist/assets/appThemeRegistry-DLicw4dH.js +1 -0
- package/dist/assets/commitAndPush-BDftrjr6.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DlWlEM3x.js} +1 -1
- package/dist/assets/de-Bro6_zyl.js +10 -0
- package/dist/assets/es-Bb9XQZvd.js +16 -0
- package/dist/assets/fr-Dgfzn-IL.js +14 -0
- package/dist/assets/{index-eo1-D_7z.js → index-CtQNmqUL.js} +1 -1
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/index-Dqc6USl3.js +40 -0
- package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-CO4e5J_S.js} +1 -1
- package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-ue6kCou7.js} +1 -1
- package/dist/assets/ja-BYsakGFh.js +16 -0
- package/dist/assets/ko-BSaQz9aN.js +16 -0
- package/dist/assets/main-B62_pjL5.js +2 -0
- package/dist/assets/main-BEdcOI2P.js +896 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-CK48LG0z.js +5 -0
- package/dist/assets/miniChat-LT8Rqofr.js +2 -0
- package/dist/assets/mobile-CqarEynh.js +2 -0
- package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-D7QVlovD.js} +3 -3
- package/dist/assets/pl-OIviPdoj.js +16 -0
- package/dist/assets/pt-BR-Cltg-E6s.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +2 -0
- package/dist/assets/renderMobileApp-BC1MHg6g.js +25 -0
- package/dist/assets/runtimeEndpointReset-DdrazOVH.js +25 -0
- package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-B2SWMGEU.js} +1 -1
- package/dist/assets/shikiHighlight-BPu3JiQs.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-DWqRPo9A.js +1 -0
- package/dist/assets/tr-CQI-KPre.js +7 -0
- package/dist/assets/uk-CqW7R4DA.js +16 -0
- package/dist/assets/useAppFontEffects-ne84JBVL.js +7333 -0
- package/dist/assets/useInlineCommentController-CgS0fnw_.js +2 -0
- package/dist/assets/useRootScrollLock-Cpf1qoGp.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-Bx2EHc1O.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-DxX8bZ1o.js} +2 -2
- package/dist/assets/zh-CN-CVZG_o_J.js +16 -0
- package/dist/assets/zh-TW-D5ZxzCZp.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,184 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const PANEL_ID = /^[a-z][a-z0-9-]*$/;
|
|
6
|
+
const SETTING_KEY = /^[a-z][a-z0-9-]*$/;
|
|
7
|
+
|
|
8
|
+
const guestAuthEntrySchema = z.object({
|
|
9
|
+
clientId: z.string().min(1).optional(),
|
|
10
|
+
clientSecret: z.string().min(1).optional(),
|
|
11
|
+
accessToken: z.string().min(1).optional(),
|
|
12
|
+
refreshToken: z.string().min(1).nullable().optional(),
|
|
13
|
+
tokenType: z.string().min(1).optional(),
|
|
14
|
+
expiresAt: z.number().int().positive().nullable().optional(),
|
|
15
|
+
account: z.string().max(200).optional(),
|
|
16
|
+
settings: z.record(z.string().regex(SETTING_KEY), z.string().max(2000)).optional(),
|
|
17
|
+
authorizedAt: z.number().int().positive().optional(),
|
|
18
|
+
// Where the tokens were minted for. Checked before every use, so a
|
|
19
|
+
// package that moved its API origin or OAuth endpoints cannot receive
|
|
20
|
+
// credentials the user connected for the old ones.
|
|
21
|
+
target: z.object({
|
|
22
|
+
apiOrigin: z.string().min(1),
|
|
23
|
+
authorizeUrl: z.string().min(1).optional(),
|
|
24
|
+
tokenUrl: z.string().min(1).optional(),
|
|
25
|
+
}).optional(),
|
|
26
|
+
// Where the client id and secret were entered for. A package that moved
|
|
27
|
+
// its endpoints gets no client credentials until the user enters them
|
|
28
|
+
// again; they are never carried over.
|
|
29
|
+
clientTarget: z.object({
|
|
30
|
+
apiOrigin: z.string().min(1),
|
|
31
|
+
authorizeUrl: z.string().min(1).optional(),
|
|
32
|
+
tokenUrl: z.string().min(1).optional(),
|
|
33
|
+
}).optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const storeSchema = z.object({
|
|
37
|
+
guests: z.record(z.string().regex(PANEL_ID), guestAuthEntrySchema),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const dataDirSchema = z.string().min(1).refine((entry) => !entry.includes('\0') && path.isAbsolute(entry));
|
|
41
|
+
|
|
42
|
+
export const guestAuthPersistPath = (dataDir) => {
|
|
43
|
+
const parsed = dataDirSchema.safeParse(dataDir);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
throw new Error('Guest auth needs an absolute OpenChamber data dir');
|
|
46
|
+
}
|
|
47
|
+
return path.join(parsed.data, 'guest-auth.json');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const parseStore = (raw) => {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = storeSchema.safeParse(JSON.parse(raw));
|
|
53
|
+
return parsed.success ? parsed.data : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const readGuestAuthStore = async (persistPath) => {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(persistPath, 'utf8');
|
|
62
|
+
const text = raw.replace(/^\uFEFF/, '').trim();
|
|
63
|
+
if (text === '') {
|
|
64
|
+
return { guests: {} };
|
|
65
|
+
}
|
|
66
|
+
const parsed = parseStore(text);
|
|
67
|
+
if (!parsed) {
|
|
68
|
+
throw new Error('Invalid guest auth store');
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
73
|
+
return { guests: {} };
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
80
|
+
const writeChains = new Map();
|
|
81
|
+
let writeSequence = 0;
|
|
82
|
+
|
|
83
|
+
// Two writers at once (a token refresh while a settings field saves) used to
|
|
84
|
+
// share one temp file and a stale read: one of them lost. Every change to one
|
|
85
|
+
// store file now runs read → change → write as a single step, one after
|
|
86
|
+
// another, each on its own temp file.
|
|
87
|
+
const withAuthStoreLock = (persistPath, run) => {
|
|
88
|
+
const previous = writeChains.get(persistPath) ?? Promise.resolve();
|
|
89
|
+
const next = previous.catch(() => undefined).then(run);
|
|
90
|
+
const chained = next.finally(() => {
|
|
91
|
+
if (writeChains.get(persistPath) === chained) writeChains.delete(persistPath);
|
|
92
|
+
});
|
|
93
|
+
writeChains.set(persistPath, chained);
|
|
94
|
+
return next;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const writeGuestAuthStoreUnlocked = async (store, persistPath) => {
|
|
98
|
+
const parsed = storeSchema.safeParse(store);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
throw new Error('Invalid guest auth store');
|
|
101
|
+
}
|
|
102
|
+
await fs.mkdir(path.dirname(persistPath), { recursive: true });
|
|
103
|
+
const tmp = `${persistPath}.tmp-${process.pid}-${Date.now()}-${(writeSequence += 1)}`;
|
|
104
|
+
await fs.writeFile(tmp, `${JSON.stringify(parsed.data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
105
|
+
await fs.rename(tmp, persistPath);
|
|
106
|
+
await fs.chmod(persistPath, 0o600);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
export const getGuestAuth = async (guestId, persistPath) => {
|
|
111
|
+
const store = await readGuestAuthStore(persistPath);
|
|
112
|
+
return store.guests[guestId] ?? null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Apply `patch` to one guest's entry, read and written under the store lock.
|
|
117
|
+
* With `expect`, the patch only lands when the entry read under the lock
|
|
118
|
+
* satisfies it; a caller that spent a network round trip on the old entry
|
|
119
|
+
* (a token refresh) uses this to drop its result when the entry moved on.
|
|
120
|
+
* `patch` may be a function of the entry read under the lock, for a change
|
|
121
|
+
* that depends on what is there (a client id replacing another one).
|
|
122
|
+
* Returns the entry as stored afterwards, or `null` when it is gone.
|
|
123
|
+
*/
|
|
124
|
+
export const patchGuestAuth = (guestId, patch, persistPath, expect = null) => withAuthStoreLock(persistPath, async () => {
|
|
125
|
+
const store = await readGuestAuthStore(persistPath);
|
|
126
|
+
const current = store.guests[guestId] ?? null;
|
|
127
|
+
if (expect && !expect(current)) {
|
|
128
|
+
return current;
|
|
129
|
+
}
|
|
130
|
+
const next = { ...(current ?? {}) };
|
|
131
|
+
const resolvedPatch = typeof patch === 'function' ? patch(current) : patch;
|
|
132
|
+
for (const [key, value] of Object.entries(resolvedPatch)) {
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
delete next[key];
|
|
135
|
+
} else {
|
|
136
|
+
next[key] = value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const guests = { ...store.guests };
|
|
140
|
+
const empty = !next.clientId
|
|
141
|
+
&& !next.clientSecret
|
|
142
|
+
&& !next.accessToken
|
|
143
|
+
&& !next.refreshToken
|
|
144
|
+
&& (!next.settings || Object.keys(next.settings).length === 0);
|
|
145
|
+
if (empty) {
|
|
146
|
+
delete guests[guestId];
|
|
147
|
+
} else {
|
|
148
|
+
guests[guestId] = next;
|
|
149
|
+
}
|
|
150
|
+
await writeGuestAuthStoreUnlocked({ guests }, persistPath);
|
|
151
|
+
return guests[guestId] ?? null;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Forget the tokens. With `expect`, only when the entry read under the lock
|
|
156
|
+
* still holds the tokens the caller is reacting to; a caller that saw a 401
|
|
157
|
+
* on one access token must not erase a newer connection made meanwhile.
|
|
158
|
+
*/
|
|
159
|
+
export const dropGuestTokens = async (guestId, persistPath, expect = null) => {
|
|
160
|
+
const current = await getGuestAuth(guestId, persistPath);
|
|
161
|
+
if (!current) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return patchGuestAuth(guestId, {
|
|
165
|
+
accessToken: undefined,
|
|
166
|
+
refreshToken: undefined,
|
|
167
|
+
target: undefined,
|
|
168
|
+
tokenType: undefined,
|
|
169
|
+
expiresAt: undefined,
|
|
170
|
+
account: undefined,
|
|
171
|
+
authorizedAt: undefined,
|
|
172
|
+
}, persistPath, expect);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Drop everything stored for a guest: tokens, client credentials, and settings. Used when the package is removed. */
|
|
176
|
+
export const forgetGuestAuth = (guestId, persistPath) => withAuthStoreLock(persistPath, async () => {
|
|
177
|
+
const store = await readGuestAuthStore(persistPath);
|
|
178
|
+
if (!(guestId in store.guests)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const guests = { ...store.guests };
|
|
182
|
+
delete guests[guestId];
|
|
183
|
+
await writeGuestAuthStoreUnlocked({ ...store, guests }, persistPath);
|
|
184
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
dropGuestTokens,
|
|
8
|
+
forgetGuestAuth,
|
|
9
|
+
getGuestAuth,
|
|
10
|
+
guestAuthPersistPath,
|
|
11
|
+
patchGuestAuth,
|
|
12
|
+
readGuestAuthStore,
|
|
13
|
+
} from './auth-store.js';
|
|
14
|
+
|
|
15
|
+
describe('guestAuthPersistPath', () => {
|
|
16
|
+
test('joins the instance data dir and refuses a relative path', () => {
|
|
17
|
+
const dataDir = path.join(os.tmpdir(), 'oc-guest-auth-a');
|
|
18
|
+
expect(guestAuthPersistPath(dataDir)).toBe(path.join(dataDir, 'guest-auth.json'));
|
|
19
|
+
expect(() => guestAuthPersistPath('relative')).toThrow('absolute OpenChamber data dir');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('guest auth store', () => {
|
|
24
|
+
test('round-trips tokens and writes 0o600', async () => {
|
|
25
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-guest-auth-'));
|
|
26
|
+
const file = guestAuthPersistPath(dir);
|
|
27
|
+
expect(await readGuestAuthStore(file)).toEqual({ guests: {} });
|
|
28
|
+
await patchGuestAuth('clickup', {
|
|
29
|
+
clientId: 'id',
|
|
30
|
+
clientSecret: 'secret',
|
|
31
|
+
accessToken: 'tok',
|
|
32
|
+
account: 'ada',
|
|
33
|
+
settings: { 'list-id': '123' },
|
|
34
|
+
}, file);
|
|
35
|
+
expect(await getGuestAuth('clickup', file)).toMatchObject({
|
|
36
|
+
clientId: 'id',
|
|
37
|
+
accessToken: 'tok',
|
|
38
|
+
account: 'ada',
|
|
39
|
+
settings: { 'list-id': '123' },
|
|
40
|
+
});
|
|
41
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
42
|
+
await dropGuestTokens('clickup', file);
|
|
43
|
+
const after = await getGuestAuth('clickup', file);
|
|
44
|
+
expect(after?.accessToken).toBeUndefined();
|
|
45
|
+
expect(after?.clientId).toBe('id');
|
|
46
|
+
expect(after?.settings).toEqual({ 'list-id': '123' });
|
|
47
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('forgets a guest entirely when its package is removed', async () => {
|
|
51
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-guest-auth-'));
|
|
52
|
+
const file = guestAuthPersistPath(dir);
|
|
53
|
+
try {
|
|
54
|
+
await patchGuestAuth('clickup', { clientId: 'app', clientSecret: 'secret', accessToken: 'token' }, file);
|
|
55
|
+
await patchGuestAuth('other', { accessToken: 'keep' }, file);
|
|
56
|
+
await forgetGuestAuth('clickup', file);
|
|
57
|
+
expect(await getGuestAuth('clickup', file)).toBeNull();
|
|
58
|
+
expect((await getGuestAuth('other', file))?.accessToken).toBe('keep');
|
|
59
|
+
} finally {
|
|
60
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('treats a blank file as an empty store', async () => {
|
|
65
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-guest-auth-'));
|
|
66
|
+
const file = guestAuthPersistPath(dir);
|
|
67
|
+
await fs.promises.writeFile(file, '', 'utf8');
|
|
68
|
+
expect(await readGuestAuthStore(file)).toEqual({ guests: {} });
|
|
69
|
+
await fs.promises.writeFile(file, '\uFEFF\n', 'utf8');
|
|
70
|
+
expect(await readGuestAuthStore(file)).toEqual({ guests: {} });
|
|
71
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('refuses a corrupt store', async () => {
|
|
75
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-guest-auth-'));
|
|
76
|
+
const file = guestAuthPersistPath(dir);
|
|
77
|
+
await fs.promises.writeFile(file, '{"guests":[]}', 'utf8');
|
|
78
|
+
try {
|
|
79
|
+
await readGuestAuthStore(file);
|
|
80
|
+
throw new Error('should have thrown');
|
|
81
|
+
} catch (error) {
|
|
82
|
+
expect(String(error)).toContain('Invalid guest auth store');
|
|
83
|
+
}
|
|
84
|
+
await fs.promises.writeFile(file, '{', 'utf8');
|
|
85
|
+
try {
|
|
86
|
+
await readGuestAuthStore(file);
|
|
87
|
+
throw new Error('should have thrown');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
expect(String(error)).toContain('Invalid guest auth store');
|
|
90
|
+
}
|
|
91
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('concurrent auth changes', () => {
|
|
96
|
+
test('two patches at once both land', async () => {
|
|
97
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-guest-auth-'));
|
|
98
|
+
const file = guestAuthPersistPath(dir);
|
|
99
|
+
try {
|
|
100
|
+
await Promise.all([
|
|
101
|
+
patchGuestAuth('alpha', { accessToken: 'tok-a' }, file),
|
|
102
|
+
patchGuestAuth('beta', { accessToken: 'tok-b' }, file),
|
|
103
|
+
patchGuestAuth('alpha', { settings: { 'list-id': '7' } }, file),
|
|
104
|
+
]);
|
|
105
|
+
expect(await getGuestAuth('alpha', file)).toMatchObject({ accessToken: 'tok-a', settings: { 'list-id': '7' } });
|
|
106
|
+
expect(await getGuestAuth('beta', file)).toMatchObject({ accessToken: 'tok-b' });
|
|
107
|
+
expect(fs.readdirSync(dir).filter((name) => name.includes('.tmp-'))).toEqual([]);
|
|
108
|
+
} finally {
|
|
109
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, 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 express from 'express';
|
|
6
|
+
import request from 'supertest';
|
|
7
|
+
import { inspectGuestPackage, toPublicGuest } from './catalog.js';
|
|
8
|
+
import { registerGuestRoutes } from './routes.js';
|
|
9
|
+
|
|
10
|
+
const roots = [];
|
|
11
|
+
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
|
|
12
|
+
const fixture = async () => {
|
|
13
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-background-')); roots.push(root);
|
|
14
|
+
const packageRoot = path.join(root, 'package');
|
|
15
|
+
await fs.mkdir(packageRoot);
|
|
16
|
+
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'background-test', version: '1.0.0', openchamber: {
|
|
17
|
+
apiVersion: 1, contributes: {
|
|
18
|
+
panel: { id: 'background-test', name: 'Background Test', icon: 'apps' },
|
|
19
|
+
background: { entry: 'index.html' },
|
|
20
|
+
capabilities: ['files'], commands: [{ name: 'test-background' }],
|
|
21
|
+
actions: [{ id: 'count', label: 'Count', where: 'message', mode: 'background' }],
|
|
22
|
+
},
|
|
23
|
+
} }));
|
|
24
|
+
return { root, packageRoot };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('background-only packages validate HTML and built scripts and expose a separate catalog entry', async () => {
|
|
28
|
+
const { packageRoot } = await fixture();
|
|
29
|
+
expect(await inspectGuestPackage(packageRoot)).toMatchObject({ ok: false, code: 'invalid-manifest' });
|
|
30
|
+
await fs.writeFile(path.join(packageRoot, 'index.html'), '<script src="main.js"></script>');
|
|
31
|
+
await fs.writeFile(path.join(packageRoot, 'main.ts'), 'export {};');
|
|
32
|
+
expect(await inspectGuestPackage(packageRoot)).toMatchObject({ ok: false, code: 'missing-build' });
|
|
33
|
+
await fs.writeFile(path.join(packageRoot, 'main.js'), 'void 0;');
|
|
34
|
+
const result = await inspectGuestPackage(packageRoot);
|
|
35
|
+
expect(result.ok).toBe(true);
|
|
36
|
+
if (!result.ok) throw new Error(result.code);
|
|
37
|
+
const row = toPublicGuest(result.guest);
|
|
38
|
+
expect(row.entry).toBeUndefined();
|
|
39
|
+
expect(row.backgroundEntry).toBe('index.html');
|
|
40
|
+
expect(row.attach).toBeUndefined();
|
|
41
|
+
expect(row.actions).toHaveLength(1);
|
|
42
|
+
expect(row.commands).toHaveLength(1);
|
|
43
|
+
expect(row.capabilities).toEqual({ requested: ['files'], granted: [] });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('background entry and its scripts cannot escape the installed package through symlinks', async () => {
|
|
47
|
+
const { root, packageRoot } = await fixture();
|
|
48
|
+
const outside = path.join(root, 'outside.html');
|
|
49
|
+
await fs.writeFile(outside, '<p>Outside</p>');
|
|
50
|
+
await fs.symlink(outside, path.join(packageRoot, 'index.html'));
|
|
51
|
+
expect(await inspectGuestPackage(packageRoot)).toMatchObject({ ok: false, code: 'invalid-manifest' });
|
|
52
|
+
await fs.unlink(path.join(packageRoot, 'index.html'));
|
|
53
|
+
await fs.writeFile(path.join(packageRoot, 'index.html'), '<script src="main.js"></script>');
|
|
54
|
+
await fs.writeFile(path.join(root, 'outside.js'), 'void 0;');
|
|
55
|
+
await fs.symlink(path.join(root, 'outside.js'), path.join(packageRoot, 'main.js'));
|
|
56
|
+
expect(await inspectGuestPackage(packageRoot)).toMatchObject({ ok: false, code: 'missing-build' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('background install serves sandboxed assets and storage obeys approval and enabled state', async () => {
|
|
60
|
+
const { root, packageRoot } = await fixture();
|
|
61
|
+
await fs.writeFile(path.join(packageRoot, 'index.html'), '<script src="main.js"></script>');
|
|
62
|
+
await fs.writeFile(path.join(packageRoot, 'main.js'), 'void 0;');
|
|
63
|
+
const app = express(); registerGuestRoutes(app, { openchamberDataDir: root, resolveGitBinaryForSpawn: () => 'git' });
|
|
64
|
+
const installed = await request(app).post('/api/guests').send({ path: packageRoot }).expect(201);
|
|
65
|
+
expect(installed.body.guest.backgroundEntry).toBe('index.html');
|
|
66
|
+
expect(installed.body.guest.entry).toBeUndefined();
|
|
67
|
+
const catalog = await request(app).get('/api/guests').expect(200);
|
|
68
|
+
expect(catalog.body.guests[0].backgroundEntry).toBe('index.html');
|
|
69
|
+
const html = await request(app).get('/api/guests/background-test/index.html?oc_url_token=test-scope').expect(200);
|
|
70
|
+
expect(html.headers['content-security-policy']).toBe('sandbox allow-scripts');
|
|
71
|
+
expect(html.text).toContain('main.js?oc_url_token=test-scope');
|
|
72
|
+
expect((await request(app).get('/api/guests/background-test/main.js').expect(200)).text).toBe('void 0;');
|
|
73
|
+
await request(app).post('/api/guests/background-test/storage').send({ op: 'set', key: 'count', value: 1 }).expect(400);
|
|
74
|
+
await request(app).put('/api/guests/background-test/capabilities').send({ granted: ['files'] }).expect(200);
|
|
75
|
+
await request(app).post('/api/guests/background-test/storage').send({ op: 'set', key: 'count', value: 1 }).expect(200);
|
|
76
|
+
expect((await request(app).post('/api/guests/background-test/storage').send({ op: 'get', key: 'count' }).expect(200)).body).toMatchObject({ found: true, value: 1 });
|
|
77
|
+
await request(app).put('/api/guests/background-test/enabled').send({ enabled: false }).expect(200);
|
|
78
|
+
await request(app).post('/api/guests/background-test/storage').send({ op: 'set', key: 'count', value: 2 }).expect(400);
|
|
79
|
+
await request(app).put('/api/guests/background-test/enabled').send({ enabled: true }).expect(200);
|
|
80
|
+
expect((await request(app).post('/api/guests/background-test/storage').send({ op: 'get', key: 'count' }).expect(200)).body).toMatchObject({ value: 1 });
|
|
81
|
+
await request(app).put('/api/guests/background-test/capabilities').send({ granted: [] }).expect(200);
|
|
82
|
+
await request(app).post('/api/guests/background-test/storage').send({ op: 'get', key: 'count' }).expect(400);
|
|
83
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const BUILTIN_PREFIX = 'openchamber-builtin-';
|
|
7
|
+
export const DEFAULT_BUILTIN_ROOT = fileURLToPath(new URL('../../built-in-extensions/', import.meta.url));
|
|
8
|
+
export const builtInExtensionSchema = z.object({
|
|
9
|
+
id: z.string().regex(/^openchamber-builtin-[a-z][a-z0-9-]*$/),
|
|
10
|
+
directory: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
11
|
+
});
|
|
12
|
+
export const builtInRegistrySchema = z.object({
|
|
13
|
+
version: z.literal(1),
|
|
14
|
+
extensions: z.array(builtInExtensionSchema),
|
|
15
|
+
}).superRefine(({ extensions }, context) => {
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
const directories = new Set();
|
|
18
|
+
for (const entry of extensions) {
|
|
19
|
+
if (ids.has(entry.id) || directories.has(entry.directory)) {
|
|
20
|
+
context.addIssue({ code: 'custom', message: 'Built-in extension IDs and directories must be unique' });
|
|
21
|
+
}
|
|
22
|
+
ids.add(entry.id);
|
|
23
|
+
directories.add(entry.directory);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const isReservedBuiltInId = (id) => id.startsWith(BUILTIN_PREFIX);
|
|
28
|
+
|
|
29
|
+
/** Only application startup supplies this root; manifests and HTTP input cannot select it. */
|
|
30
|
+
export const readBuiltInRegistry = async (root = DEFAULT_BUILTIN_ROOT) => {
|
|
31
|
+
const realRoot = await fs.realpath(root);
|
|
32
|
+
const registry = builtInRegistrySchema.parse(JSON.parse(await fs.readFile(path.join(realRoot, 'registry.json'), 'utf8')));
|
|
33
|
+
return { root: realRoot, extensions: registry.extensions };
|
|
34
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, describe, expect, spyOn, 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 express from 'express';
|
|
6
|
+
import request from 'supertest';
|
|
7
|
+
|
|
8
|
+
import { registerBuiltInGuests, listInstalledGuests, invalidateGuestCatalog, toPublicGuest } from './catalog.js';
|
|
9
|
+
import { registerGuestRoutes } from './routes.js';
|
|
10
|
+
import { readExtensionStore, writeExtensionStore } from './persist.js';
|
|
11
|
+
import { getServiceStatus, stopAllGuestServices } from './service.js';
|
|
12
|
+
|
|
13
|
+
const roots = [];
|
|
14
|
+
const releases = [];
|
|
15
|
+
const id = 'openchamber-builtin-fixture';
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await stopAllGuestServices();
|
|
18
|
+
for (const release of releases.splice(0)) release();
|
|
19
|
+
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const manifest = (panelId = id) => ({
|
|
23
|
+
name: 'fixture', version: '1.0.0', type: 'module',
|
|
24
|
+
openchamber: { apiVersion: 1, contributes: {
|
|
25
|
+
panel: { id: panelId, name: 'Fixture', icon: 'puzzle', entry: 'panel/index.html' },
|
|
26
|
+
capabilities: ['files', 'sessions'],
|
|
27
|
+
integration: { name: 'Fixture', description: 'GitHub', token: { apiOrigin: 'https://api.github.com', scheme: 'bearer', account: { path: '/user', name: 'login' } } },
|
|
28
|
+
} },
|
|
29
|
+
});
|
|
30
|
+
const writePackage = async (root, definition) => {
|
|
31
|
+
await fs.mkdir(path.join(root, 'panel'), { recursive: true });
|
|
32
|
+
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(definition));
|
|
33
|
+
await fs.writeFile(path.join(root, 'panel/index.html'), '<script src="main.js"></script>');
|
|
34
|
+
await fs.writeFile(path.join(root, 'panel/main.js'), 'console.log("fixture");');
|
|
35
|
+
};
|
|
36
|
+
const fixture = async () => {
|
|
37
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-builtins-'));
|
|
38
|
+
roots.push(root);
|
|
39
|
+
const bundle = path.join(root, 'app-bundle');
|
|
40
|
+
const packageRoot = path.join(bundle, 'fixture');
|
|
41
|
+
const data = path.join(root, 'data');
|
|
42
|
+
const persistPath = path.join(data, 'extensions.json');
|
|
43
|
+
const project = path.join(root, 'project');
|
|
44
|
+
await fs.mkdir(project);
|
|
45
|
+
await fs.writeFile(path.join(project, 'README.md'), 'Fixture project');
|
|
46
|
+
await writePackage(packageRoot, manifest());
|
|
47
|
+
await fs.writeFile(path.join(bundle, 'registry.json'), JSON.stringify({ version: 1, extensions: [{ id, directory: 'fixture' }] }));
|
|
48
|
+
releases.push(await registerBuiltInGuests({ persistPath, root: bundle }));
|
|
49
|
+
const app = express();
|
|
50
|
+
registerGuestRoutes(app, { openchamberDataDir: data, openchamberVersion: '1.23.2', resolveGitBinaryForSpawn: () => 'git', resolveOptionalProjectDirectory: () => ({ directory: project }) });
|
|
51
|
+
return { root, bundle, packageRoot, persistPath, api: request(app) };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe('app-owned extensions', () => {
|
|
55
|
+
test('each instance owns its disabled state independently', async () => {
|
|
56
|
+
const first = await fixture();
|
|
57
|
+
const second = await fixture();
|
|
58
|
+
await first.api.put(`/api/guests/${id}/enabled`).send({ enabled: false }).expect(200);
|
|
59
|
+
expect((await first.api.get('/api/guests').expect(200)).body.guests[0].enabled).toBe(false);
|
|
60
|
+
expect((await second.api.get('/api/guests').expect(200)).body.guests[0].enabled).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
test('ships enabled with its exact declared grants and no persisted approval', async () => {
|
|
63
|
+
const { api, persistPath } = await fixture();
|
|
64
|
+
const response = await api.get('/api/guests').expect(200);
|
|
65
|
+
const guest = response.body.guests[0];
|
|
66
|
+
expect(guest.source).toBe('bundled');
|
|
67
|
+
expect(guest.enabled).toBe(true);
|
|
68
|
+
expect(guest.path).toBeNull();
|
|
69
|
+
expect([...guest.capabilities.granted].sort()).toEqual([...guest.capabilities.requested].sort());
|
|
70
|
+
expect(guest.capabilities.granted).toContain('files');
|
|
71
|
+
expect(guest.capabilities.granted).toContain('network');
|
|
72
|
+
expect((await readExtensionStore(persistPath)).capabilityGrants).toEqual({});
|
|
73
|
+
await api.post(`/api/guests/${id}/files`).send({ op: 'read', path: 'README.md' }).expect(200);
|
|
74
|
+
const deniedModel = await api.post(`/api/guests/${id}/generate`).send({ prompt: 'not declared' }).expect(400);
|
|
75
|
+
expect(deniedModel.body.error).toBe('NOT_GRANTED');
|
|
76
|
+
await api.put(`/api/guests/${id}/capabilities`).send({ granted: [] }).expect(400, { error: 'bundled' });
|
|
77
|
+
await api.delete(`/api/guests/${id}`).expect(400, { error: 'bundled' });
|
|
78
|
+
await api.post(`/api/guests/${id}/update`).expect(400, { error: 'not-git' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('disable survives a new application version and preserves storage and credentials', async () => {
|
|
82
|
+
const { api, persistPath, packageRoot, bundle } = await fixture();
|
|
83
|
+
await api.post(`/api/guests/${id}/storage`).send({ op: 'set', key: 'note', value: 'keep this' }).expect(200);
|
|
84
|
+
const fetch = spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ login: 'fixture' }));
|
|
85
|
+
try {
|
|
86
|
+
await api.put(`/api/guests/${id}/token`).send({ token: 'fixture-token' }).expect(200);
|
|
87
|
+
} finally { fetch.mockRestore(); }
|
|
88
|
+
await api.put(`/api/guests/${id}/enabled`).send({ enabled: false }).expect(200);
|
|
89
|
+
const deniedFile = await api.post(`/api/guests/${id}/files`).send({ op: 'read', path: 'README.md' }).expect(400);
|
|
90
|
+
expect(deniedFile.body.error).toBe('DISABLED');
|
|
91
|
+
const blockedFetch = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Disabled extension reached the provider'));
|
|
92
|
+
try {
|
|
93
|
+
const deniedRequest = await api.post(`/api/guests/${id}/request`).send({ method: 'GET', path: '/user' }).expect(400);
|
|
94
|
+
expect(deniedRequest.body.error).toBe('DISABLED');
|
|
95
|
+
expect(blockedFetch.mock.calls).toHaveLength(0);
|
|
96
|
+
} finally { blockedFetch.mockRestore(); }
|
|
97
|
+
const next = manifest();
|
|
98
|
+
next.version = '2.0.0';
|
|
99
|
+
next.openchamber.contributes.capabilities.push('model');
|
|
100
|
+
await writePackage(packageRoot, next);
|
|
101
|
+
releases.push(await registerBuiltInGuests({ persistPath, root: bundle }));
|
|
102
|
+
const updated = (await api.get('/api/guests').expect(200)).body.guests[0];
|
|
103
|
+
expect(updated.enabled).toBe(false);
|
|
104
|
+
expect(updated.version).toBe('2.0.0');
|
|
105
|
+
expect(updated.capabilities.granted).toContain('model');
|
|
106
|
+
expect((await readExtensionStore(persistPath)).disabledGuests[id]).toBe(true);
|
|
107
|
+
await api.put(`/api/guests/${id}/enabled`).send({ enabled: true }).expect(200);
|
|
108
|
+
const saved = await api.post(`/api/guests/${id}/storage`).send({ op: 'get', key: 'note' }).expect(200);
|
|
109
|
+
expect(saved.body.value).toBe('keep this');
|
|
110
|
+
expect((await api.get(`/api/guests/${id}/oauth/status`).expect(200)).body.connected).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a package cannot claim built-in trust or replace a reserved ID', async () => {
|
|
114
|
+
const { api, root, persistPath } = await fixture();
|
|
115
|
+
const forged = path.join(root, 'forged');
|
|
116
|
+
const definition = manifest('third-party');
|
|
117
|
+
definition.builtin = true;
|
|
118
|
+
definition.openchamber.builtin = true;
|
|
119
|
+
await writePackage(forged, definition);
|
|
120
|
+
const installed = await api.post('/api/guests').send({ path: forged, source: 'bundled' }).expect(201);
|
|
121
|
+
expect(installed.body.guest.source).toBe('path');
|
|
122
|
+
expect(installed.body.guest.capabilities.granted).toEqual([]);
|
|
123
|
+
await writePackage(forged, manifest());
|
|
124
|
+
await api.post('/api/guests').send({ path: forged, replace: true }).expect(400, { error: 'reserved-id' });
|
|
125
|
+
await writeExtensionStore(persistPath, { paths: [forged], sources: {}, capabilityGrants: { [id]: ['files', 'network'] } });
|
|
126
|
+
const guests = await listInstalledGuests({ persistPath });
|
|
127
|
+
expect(guests).toHaveLength(1);
|
|
128
|
+
expect(guests[0].source).toBe('bundled');
|
|
129
|
+
expect(guests[0].packageRoot).not.toBe(forged);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('automatic grants never move a saved token to a changed provider origin', async () => {
|
|
133
|
+
const { api, packageRoot, persistPath } = await fixture();
|
|
134
|
+
const fetch = spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ login: 'fixture' }));
|
|
135
|
+
try { await api.put(`/api/guests/${id}/token`).send({ token: 'fixture-token' }).expect(200); }
|
|
136
|
+
finally { fetch.mockRestore(); }
|
|
137
|
+
const definition = manifest();
|
|
138
|
+
definition.openchamber.contributes.integration.token.apiOrigin = 'https://api.example.com';
|
|
139
|
+
await writePackage(packageRoot, definition);
|
|
140
|
+
invalidateGuestCatalog(persistPath);
|
|
141
|
+
expect((await api.get('/api/guests').expect(200)).body.guests[0].capabilities.granted).toContain('network');
|
|
142
|
+
expect((await api.get(`/api/guests/${id}/oauth/status`).expect(200)).body.connected).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('rejects invalid registries and does not elevate symlinked packages outside the bundle', async () => {
|
|
146
|
+
const { root, bundle, persistPath } = await fixture();
|
|
147
|
+
const outside = path.join(root, 'outside');
|
|
148
|
+
await writePackage(outside, manifest());
|
|
149
|
+
const broken = path.join(bundle, 'linked');
|
|
150
|
+
await fs.symlink(outside, broken, 'dir');
|
|
151
|
+
await fs.writeFile(path.join(bundle, 'registry.json'), JSON.stringify({ version: 1, extensions: [{ id, directory: '../outside' }] }));
|
|
152
|
+
await expect(registerBuiltInGuests({ persistPath, root: bundle })).rejects.toThrow();
|
|
153
|
+
const otherId = 'openchamber-builtin-linked';
|
|
154
|
+
await fs.writeFile(path.join(bundle, 'registry.json'), JSON.stringify({ version: 1, extensions: [{ id, directory: 'fixture' }, { id: otherId, directory: 'linked' }] }));
|
|
155
|
+
releases.push(await registerBuiltInGuests({ persistPath, root: bundle }));
|
|
156
|
+
const guests = await listInstalledGuests({ persistPath });
|
|
157
|
+
expect(guests.map((guest) => guest.id)).toEqual([id]);
|
|
158
|
+
expect(toPublicGuest(guests[0]).capabilities.granted).toContain('network');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('disable stops an automatically approved built-in service', async () => {
|
|
162
|
+
const { api, packageRoot, persistPath } = await fixture();
|
|
163
|
+
const definition = manifest();
|
|
164
|
+
definition.openchamber.contributes.service = { runtime: 'host', entry: 'service.mjs' };
|
|
165
|
+
await fs.writeFile(path.join(packageRoot, 'service.mjs'), `import http from 'node:http';
|
|
166
|
+
http.createServer((req,res)=>{ if(req.headers.authorization!=='Bearer '+process.env.OPENCHAMBER_SERVICE_TOKEN){res.writeHead(401);res.end();return;}res.end('ok'); }).listen(Number(process.env.OPENCHAMBER_SERVICE_PORT),'127.0.0.1');`);
|
|
167
|
+
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify(definition));
|
|
168
|
+
invalidateGuestCatalog(persistPath);
|
|
169
|
+
await api.post(`/api/guests/${id}/service/request`).send({ method: 'GET', path: '/test' }).expect(200);
|
|
170
|
+
expect(getServiceStatus(id)).toBe('ready');
|
|
171
|
+
await api.put(`/api/guests/${id}/enabled`).send({ enabled: false }).expect(200);
|
|
172
|
+
expect(getServiceStatus(id)).toBe('stopped');
|
|
173
|
+
});
|
|
174
|
+
});
|