@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,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import request from 'supertest';
|
|
7
|
+
import { createThemeRuntime } from './theme-runtime.js';
|
|
8
|
+
import { registerSettingsUtilityRoutes, registerCommonRequestMiddleware } from './core-routes.js';
|
|
9
|
+
|
|
10
|
+
const directories = [];
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const definition = {
|
|
16
|
+
metadata: { id: '../../escape', name: 'Imported fixture', variant: 'dark' },
|
|
17
|
+
colors: {
|
|
18
|
+
primary: { base: '#ffcc66' },
|
|
19
|
+
surface: { background: '#111111', foreground: '#eeeeee', muted: '#181818', mutedForeground: '#999999', elevated: '#222222' },
|
|
20
|
+
interactive: { border: '#333333' },
|
|
21
|
+
status: { error: '#ff6666', warning: '#ffcc66', success: '#66cc66', info: '#66aaff' },
|
|
22
|
+
syntax: { base: { comment: '#888888', keyword: '#aa88ff', string: '#88cc66', number: '#ffcc66', function: '#66aaff', variable: '#eeeeee', type: '#66dddd', operator: '#ff6677' } },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function setup(overrides = {}) {
|
|
27
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-theme-import-'));
|
|
28
|
+
directories.push(root);
|
|
29
|
+
const themesDir = path.join(root, 'themes');
|
|
30
|
+
const runtime = createThemeRuntime({ fsPromises: { ...fs, ...overrides }, path, themesDir, maxThemeJsonBytes: 512 * 1024, logger: console });
|
|
31
|
+
const app = express();
|
|
32
|
+
registerCommonRequestMiddleware(app, { express });
|
|
33
|
+
registerSettingsUtilityRoutes(app, runtime);
|
|
34
|
+
return { root, themesDir, runtime, app };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('theme import persistence', () => {
|
|
38
|
+
it('deletes only imported files, leaves siblings intact and allows an identical reimport', async () => {
|
|
39
|
+
const { app, runtime, themesDir } = await setup();
|
|
40
|
+
const theme = await runtime.saveImportedTheme(definition);
|
|
41
|
+
await fs.writeFile(path.join(themesDir, 'custom.json'), JSON.stringify(definition));
|
|
42
|
+
await request(app).delete('/api/config/themes/openchamber-dark').expect(200);
|
|
43
|
+
await request(app).delete(`/api/config/themes/${theme.metadata.id}`).expect(200);
|
|
44
|
+
expect(await fs.readdir(themesDir)).toEqual(['custom.json']);
|
|
45
|
+
await request(app).delete(`/api/config/themes/${theme.metadata.id}`).expect(200);
|
|
46
|
+
expect(await runtime.saveImportedTheme(definition)).toEqual(theme);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not follow a symlink during deletion or report deletion failure as success', async () => {
|
|
50
|
+
const { app, root, themesDir } = await setup();
|
|
51
|
+
await fs.mkdir(themesDir);
|
|
52
|
+
const outside = path.join(root, 'outside.json');
|
|
53
|
+
await fs.writeFile(outside, 'keep');
|
|
54
|
+
const id = `imported-vscode-${'a'.repeat(24)}`;
|
|
55
|
+
await fs.symlink(outside, path.join(themesDir, `${id}.json`));
|
|
56
|
+
await request(app).delete(`/api/config/themes/${id}`).expect(200);
|
|
57
|
+
expect(await fs.readFile(outside, 'utf8')).toBe('keep');
|
|
58
|
+
const denied = await setup({ unlink: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); } });
|
|
59
|
+
await fs.mkdir(denied.themesDir);
|
|
60
|
+
await fs.writeFile(path.join(denied.themesDir, `${id}.json`), JSON.stringify({ ...definition, metadata: { ...definition.metadata, id } }));
|
|
61
|
+
await request(denied.app).delete(`/api/config/themes/${id}`).expect(500);
|
|
62
|
+
expect(await fs.readdir(denied.themesDir)).toEqual([`${id}.json`]);
|
|
63
|
+
});
|
|
64
|
+
it('deletes a hand-added theme by its metadata ID, not by an assumed filename', async () => {
|
|
65
|
+
const { app, themesDir } = await setup();
|
|
66
|
+
await fs.mkdir(themesDir);
|
|
67
|
+
const custom = { ...definition, metadata: { ...definition.metadata, id: 't3-code-dark', name: 'T3 Code' } };
|
|
68
|
+
await fs.writeFile(path.join(themesDir, 'my-custom-palette.json'), JSON.stringify(custom));
|
|
69
|
+
const loaded = await request(app).get('/api/config/themes').expect(200);
|
|
70
|
+
expect(loaded.body.themes[0].metadata.id).toBe('t3-code-dark');
|
|
71
|
+
await request(app).delete('/api/config/themes/t3-code-dark').expect(200);
|
|
72
|
+
expect(await fs.readdir(themesDir)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('refuses ambiguous duplicate IDs and does not turn an ID into a filesystem path', async () => {
|
|
76
|
+
const { app, themesDir, root } = await setup();
|
|
77
|
+
await fs.mkdir(themesDir);
|
|
78
|
+
const custom = { ...definition, metadata: { ...definition.metadata, id: 'duplicate' } };
|
|
79
|
+
for (const name of ['one.json', 'two.json']) await fs.writeFile(path.join(themesDir, name), JSON.stringify(custom));
|
|
80
|
+
await request(app).delete('/api/config/themes/duplicate').expect(409);
|
|
81
|
+
expect((await fs.readdir(themesDir)).sort()).toEqual(['one.json', 'two.json']);
|
|
82
|
+
await fs.writeFile(path.join(root, 'outside.json'), JSON.stringify(custom));
|
|
83
|
+
await request(app).delete('/api/config/themes/..%2Foutside').expect(200);
|
|
84
|
+
expect(await fs.readFile(path.join(root, 'outside.json'), 'utf8')).toBe(JSON.stringify(custom));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('allows deleting a readable theme beside an unreadable sibling, but does not treat failed reads as absence', async () => {
|
|
88
|
+
const { runtime, themesDir } = await setup({ readFile: async (file, ...args) => {
|
|
89
|
+
if (path.basename(file) === 'unreadable.json') throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
|
90
|
+
return fs.readFile(file, ...args);
|
|
91
|
+
} });
|
|
92
|
+
const theme = await runtime.saveImportedTheme(definition);
|
|
93
|
+
await fs.writeFile(path.join(themesDir, 'unreadable.json'), JSON.stringify(definition));
|
|
94
|
+
await runtime.deleteImportedTheme(theme.metadata.id);
|
|
95
|
+
expect(await fs.readdir(themesDir)).toEqual(['unreadable.json']);
|
|
96
|
+
await expect(runtime.deleteImportedTheme('possibly-unreadable')).rejects.toMatchObject({ code: 'EACCES' });
|
|
97
|
+
});
|
|
98
|
+
it('saves through the API under a server-owned ID and returns it through reload', async () => {
|
|
99
|
+
const { app, themesDir, root } = await setup();
|
|
100
|
+
const response = await request(app).post('/api/config/themes').send({ theme: definition }).expect(201);
|
|
101
|
+
expect(response.body.theme.metadata.id).toMatch(/^imported-vscode-[a-f0-9]{24}$/);
|
|
102
|
+
expect(await fs.readdir(root)).toEqual(['themes']);
|
|
103
|
+
expect(await fs.readdir(themesDir)).toEqual([`${response.body.theme.metadata.id}.json`]);
|
|
104
|
+
const loaded = await request(app).get('/api/config/themes').expect(200);
|
|
105
|
+
expect(loaded.body.themes).toEqual([response.body.theme]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('deduplicates simultaneous imports and never overwrites a subsequently edited theme', async () => {
|
|
109
|
+
const { runtime, themesDir } = await setup();
|
|
110
|
+
const [first, second] = await Promise.all([runtime.saveImportedTheme(definition), runtime.saveImportedTheme(definition)]);
|
|
111
|
+
expect(first).toEqual(second);
|
|
112
|
+
expect((await fs.readdir(themesDir)).length).toBe(1);
|
|
113
|
+
const filename = path.join(themesDir, `${first.metadata.id}.json`);
|
|
114
|
+
await fs.writeFile(filename, 'manually edited');
|
|
115
|
+
await expect(runtime.saveImportedTheme(definition)).rejects.toMatchObject({ code: 'conflict', status: 409 });
|
|
116
|
+
expect(await fs.readFile(filename, 'utf8')).toBe('manually edited');
|
|
117
|
+
expect(await fs.readdir(themesDir)).toEqual([`${first.metadata.id}.json`]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects malformed definitions and unsafe token names without creating files', async () => {
|
|
121
|
+
const { app, root } = await setup();
|
|
122
|
+
await request(app).post('/api/config/themes').send({ theme: { ...definition, colors: {} } }).expect(400);
|
|
123
|
+
await request(app).post('/api/config/themes').send({ theme: { ...definition, colors: { ...definition.colors, syntax: { ...definition.colors.syntax, tokens: { 'x;body': '#000000' } } } } }).expect(400);
|
|
124
|
+
expect(await fs.readdir(root)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('cleans temporary files after a publication failure', async () => {
|
|
128
|
+
const { runtime, themesDir } = await setup({ link: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); } });
|
|
129
|
+
await expect(runtime.saveImportedTheme(definition)).rejects.toMatchObject({ code: 'EACCES' });
|
|
130
|
+
expect(await fs.readdir(themesDir)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not report a directory read failure as an empty theme library', async () => {
|
|
134
|
+
const { runtime } = await setup({ readdir: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); } });
|
|
135
|
+
await expect(runtime.readCustomThemesFromDisk()).rejects.toMatchObject({ code: 'EACCES' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects oversized imports without touching the themes directory', async () => {
|
|
139
|
+
const { runtime, root } = await setup();
|
|
140
|
+
const tokens = Object.fromEntries(Array.from({ length: 30000 }, (_, index) => [`token${index}`, '#123456']));
|
|
141
|
+
await expect(runtime.saveImportedTheme({ ...definition, colors: { ...definition.colors, syntax: { ...definition.colors.syntax, tokens } } })).rejects.toMatchObject({ code: 'size', status: 413 });
|
|
142
|
+
expect(await fs.readdir(root)).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const color = z.string().regex(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i);
|
|
5
|
+
const role = z.string().regex(/^[a-zA-Z][a-zA-Z0-9]*$/).max(64);
|
|
6
|
+
const importedThemeSchema = z.object({
|
|
7
|
+
metadata: z.object({
|
|
8
|
+
name: z.string().trim().min(1).max(160),
|
|
9
|
+
author: z.string().max(160).optional(),
|
|
10
|
+
variant: z.enum(['light', 'dark']),
|
|
11
|
+
description: z.string().max(1024).default(''),
|
|
12
|
+
version: z.string().max(40).default('1.0.0'),
|
|
13
|
+
tags: z.array(z.string().max(40)).max(16).default([]),
|
|
14
|
+
}),
|
|
15
|
+
colors: z.record(role, z.record(role, z.union([color, z.record(role, color)]))),
|
|
16
|
+
});
|
|
17
|
+
const fileErrorSchema = z.object({ code: z.string() });
|
|
18
|
+
|
|
19
|
+
export class ThemeImportStorageError extends Error {
|
|
20
|
+
constructor(code, status) {
|
|
21
|
+
super(code);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
1
27
|
export const createThemeRuntime = (dependencies) => {
|
|
2
28
|
const {
|
|
3
29
|
fsPromises,
|
|
@@ -34,46 +60,24 @@ export const createThemeRuntime = (dependencies) => {
|
|
|
34
60
|
const status = colors.status;
|
|
35
61
|
const syntax = colors.syntax;
|
|
36
62
|
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
|
|
37
|
-
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
|
|
38
63
|
|
|
39
|
-
if (!primary || !surface || !interactive || !status || !syntaxBase
|
|
64
|
+
if (!primary || !surface || !interactive || !status || !syntaxBase) {
|
|
40
65
|
return null;
|
|
41
66
|
}
|
|
42
67
|
|
|
43
|
-
//
|
|
68
|
+
// Authored inputs only. The UI resolves optional roles before rendering.
|
|
44
69
|
const required = [
|
|
45
70
|
primary.base,
|
|
46
|
-
primary.foreground,
|
|
47
71
|
surface.background,
|
|
48
72
|
surface.foreground,
|
|
49
73
|
surface.muted,
|
|
50
74
|
surface.mutedForeground,
|
|
51
75
|
surface.elevated,
|
|
52
|
-
surface.elevatedForeground,
|
|
53
|
-
surface.subtle,
|
|
54
76
|
interactive.border,
|
|
55
|
-
interactive.selection,
|
|
56
|
-
interactive.selectionForeground,
|
|
57
|
-
interactive.focusRing,
|
|
58
|
-
interactive.hover,
|
|
59
77
|
status.error,
|
|
60
|
-
status.errorForeground,
|
|
61
|
-
status.errorBackground,
|
|
62
|
-
status.errorBorder,
|
|
63
78
|
status.warning,
|
|
64
|
-
status.warningForeground,
|
|
65
|
-
status.warningBackground,
|
|
66
|
-
status.warningBorder,
|
|
67
79
|
status.success,
|
|
68
|
-
status.successForeground,
|
|
69
|
-
status.successBackground,
|
|
70
|
-
status.successBorder,
|
|
71
80
|
status.info,
|
|
72
|
-
status.infoForeground,
|
|
73
|
-
status.infoBackground,
|
|
74
|
-
status.infoBorder,
|
|
75
|
-
syntaxBase.background,
|
|
76
|
-
syntaxBase.foreground,
|
|
77
81
|
syntaxBase.keyword,
|
|
78
82
|
syntaxBase.string,
|
|
79
83
|
syntaxBase.number,
|
|
@@ -82,9 +86,6 @@ export const createThemeRuntime = (dependencies) => {
|
|
|
82
86
|
syntaxBase.type,
|
|
83
87
|
syntaxBase.comment,
|
|
84
88
|
syntaxBase.operator,
|
|
85
|
-
syntaxHighlights.diffAdded,
|
|
86
|
-
syntaxHighlights.diffRemoved,
|
|
87
|
-
syntaxHighlights.lineNumber,
|
|
88
89
|
];
|
|
89
90
|
|
|
90
91
|
if (!required.every(isValidThemeColor)) {
|
|
@@ -156,12 +157,83 @@ export const createThemeRuntime = (dependencies) => {
|
|
|
156
157
|
return [];
|
|
157
158
|
}
|
|
158
159
|
logger.warn('[themes] Failed to list custom themes dir:', error);
|
|
159
|
-
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const saveImportedTheme = async (raw) => {
|
|
165
|
+
const parsed = importedThemeSchema.safeParse(raw);
|
|
166
|
+
if (!parsed.success) throw new ThemeImportStorageError('invalid', 400);
|
|
167
|
+
const normalized = normalizeThemeJson({ ...parsed.data, metadata: { ...parsed.data.metadata, id: 'import' } });
|
|
168
|
+
if (!normalized) throw new ThemeImportStorageError('invalid', 400);
|
|
169
|
+
// The client supplies colors, never a filename or an overwrite target.
|
|
170
|
+
// Identical imports have the same ID, including retries after a lost reply.
|
|
171
|
+
const digest = createHash('sha256').update(JSON.stringify(parsed.data)).digest('hex').slice(0, 24);
|
|
172
|
+
const theme = { ...normalized, metadata: { ...normalized.metadata, id: `imported-vscode-${digest}` } };
|
|
173
|
+
const contents = `${JSON.stringify(theme, null, 2)}\n`;
|
|
174
|
+
if (Buffer.byteLength(contents, 'utf8') > maxThemeJsonBytes) throw new ThemeImportStorageError('size', 413);
|
|
175
|
+
await fsPromises.mkdir(themesDir, { recursive: true });
|
|
176
|
+
const target = path.join(themesDir, `${theme.metadata.id}.json`);
|
|
177
|
+
const temporary = path.join(themesDir, `${randomUUID()}.tmp`);
|
|
178
|
+
try {
|
|
179
|
+
await fsPromises.writeFile(temporary, contents, { flag: 'wx', mode: 0o600 });
|
|
180
|
+
try {
|
|
181
|
+
// Link publishes the complete file atomically and cannot overwrite an
|
|
182
|
+
// existing theme, even if another import finishes at the same time.
|
|
183
|
+
await fsPromises.link(temporary, target);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const parsedError = fileErrorSchema.safeParse(error);
|
|
186
|
+
if (!parsedError.success || parsedError.data.code !== 'EEXIST') throw error;
|
|
187
|
+
if (await fsPromises.readFile(target, 'utf8') !== contents) throw new ThemeImportStorageError('conflict', 409);
|
|
188
|
+
}
|
|
189
|
+
return theme;
|
|
190
|
+
} finally {
|
|
191
|
+
await fsPromises.unlink(temporary).catch((error) => {
|
|
192
|
+
const parsedError = fileErrorSchema.safeParse(error);
|
|
193
|
+
if (!parsedError.success || parsedError.data.code !== 'ENOENT') logger.warn('[themes] Failed to clean import temporary file');
|
|
194
|
+
});
|
|
160
195
|
}
|
|
161
196
|
};
|
|
162
197
|
|
|
163
198
|
return {
|
|
164
199
|
normalizeThemeJson,
|
|
165
200
|
readCustomThemesFromDisk,
|
|
201
|
+
saveImportedTheme,
|
|
202
|
+
async deleteImportedTheme(id) {
|
|
203
|
+
if (!z.string().trim().min(1).max(256).safeParse(id).success) throw new ThemeImportStorageError('invalid', 400);
|
|
204
|
+
const matches = [];
|
|
205
|
+
let readFailure;
|
|
206
|
+
let entries;
|
|
207
|
+
try { entries = await fsPromises.readdir(themesDir, { withFileTypes: true }); }
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (fileErrorSchema.safeParse(error).data?.code === 'ENOENT') return;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
214
|
+
const target = path.join(themesDir, entry.name);
|
|
215
|
+
let text;
|
|
216
|
+
try {
|
|
217
|
+
const stat = await fsPromises.lstat(target);
|
|
218
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxThemeJsonBytes) continue;
|
|
219
|
+
text = await fsPromises.readFile(target, 'utf8');
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (fileErrorSchema.safeParse(error).data?.code !== 'ENOENT') readFailure = error;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let theme;
|
|
225
|
+
try { theme = normalizeThemeJson(JSON.parse(text)); }
|
|
226
|
+
catch { continue; }
|
|
227
|
+
if (theme?.metadata.id === id) matches.push(target);
|
|
228
|
+
}
|
|
229
|
+
// IDs identify themes, not filenames. Refuse ambiguous matches rather
|
|
230
|
+
// than deleting an arbitrary sibling or constructing a path from the ID.
|
|
231
|
+
if (matches.length > 1) throw new ThemeImportStorageError('conflict', 409);
|
|
232
|
+
if (!matches.length) {
|
|
233
|
+
if (readFailure) throw readFailure;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await fsPromises.unlink(matches[0]);
|
|
237
|
+
},
|
|
166
238
|
};
|
|
167
239
|
};
|
|
@@ -90,6 +90,21 @@ const createTestRuntime = ({ entries, files, stats }) => createThemeRuntime({
|
|
|
90
90
|
|
|
91
91
|
describe('theme runtime', () => {
|
|
92
92
|
describe('readCustomThemesFromDisk', () => {
|
|
93
|
+
it('loads compact authored roles without requiring derived colors', async () => {
|
|
94
|
+
const theme = validTheme('compact');
|
|
95
|
+
theme.colors.primary = { base: '#ffffff' };
|
|
96
|
+
theme.colors.interactive = { border: '#444444' };
|
|
97
|
+
theme.colors.status = { error: '#ff0000', warning: '#ffaa00', success: '#00ff00', info: '#0000ff' };
|
|
98
|
+
delete theme.colors.syntax.highlights;
|
|
99
|
+
delete theme.colors.syntax.base.background;
|
|
100
|
+
delete theme.colors.syntax.base.foreground;
|
|
101
|
+
const runtime = createTestRuntime({
|
|
102
|
+
entries: [fileEntry('compact.json'), fileEntry('bad.json')],
|
|
103
|
+
files: { '/themes/compact.json': JSON.stringify(theme), '/themes/bad.json': '{broken' },
|
|
104
|
+
stats: { '/themes/compact.json': { isFile: () => true, size: 1024 }, '/themes/bad.json': { isFile: () => true, size: 10 } },
|
|
105
|
+
});
|
|
106
|
+
expect((await runtime.readCustomThemesFromDisk()).map((item) => item.metadata.id)).toEqual(['compact']);
|
|
107
|
+
});
|
|
93
108
|
it('loads valid theme files', async () => {
|
|
94
109
|
const runtime = createTestRuntime({
|
|
95
110
|
entries: [fileEntry('direct.json')],
|
|
@@ -16,6 +16,14 @@ Policy inheritance uses the nearest explicit session value. A child `false` ther
|
|
|
16
16
|
|
|
17
17
|
Unknown lineage and failed policy loads fail closed. A failed pending-permission fetch is distinct from an empty successful response and never clears policy state.
|
|
18
18
|
|
|
19
|
+
## Safety net
|
|
20
|
+
|
|
21
|
+
`evaluatePermission` (the routing runtime, `../routing/DOCUMENTATION.md`) is
|
|
22
|
+
consulted after the policy check and before the reply. `hold` counts the
|
|
23
|
+
request as handled without replying, so it stays on screen; `accept` replies as
|
|
24
|
+
before. A `permission.replied` event is passed to `onPermissionReplied` so the
|
|
25
|
+
routing runtime forgets its cached decision.
|
|
26
|
+
|
|
19
27
|
## Routes
|
|
20
28
|
|
|
21
29
|
- `GET /api/permission-auto-accept`
|
|
@@ -25,6 +25,10 @@ export function createPermissionAutoAcceptRuntime({
|
|
|
25
25
|
readSettingsFromDiskMigrated,
|
|
26
26
|
persistSettings,
|
|
27
27
|
broadcastGlobalUiEvent,
|
|
28
|
+
// The routing safety net: asked once per request before the automatic reply.
|
|
29
|
+
// `hold` leaves the request for the user; absent means every request is replied to.
|
|
30
|
+
evaluatePermission = null,
|
|
31
|
+
onPermissionReplied = null,
|
|
28
32
|
fetchImpl = fetch,
|
|
29
33
|
retryDelaysMs = RETRY_DELAYS_MS,
|
|
30
34
|
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
@@ -148,6 +152,10 @@ export function createPermissionAutoAcceptRuntime({
|
|
|
148
152
|
if (!permission?.id || !permission?.sessionID) return false;
|
|
149
153
|
await load();
|
|
150
154
|
if (!(await isSessionAutoAccepting(permission.sessionID, directory))) return false;
|
|
155
|
+
if (evaluatePermission) {
|
|
156
|
+
const verdict = await evaluatePermission(permission, directory);
|
|
157
|
+
if (verdict?.action === 'hold') return true;
|
|
158
|
+
}
|
|
151
159
|
await request(`/permission/${encodeURIComponent(permission.id)}/reply`, {
|
|
152
160
|
directory,
|
|
153
161
|
method: 'POST',
|
|
@@ -218,6 +226,11 @@ export function createPermissionAutoAcceptRuntime({
|
|
|
218
226
|
}
|
|
219
227
|
if (payload?.type === 'permission.asked') {
|
|
220
228
|
void processPermission(payload.properties, directory);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (payload?.type === 'permission.replied') {
|
|
232
|
+
const permissionId = payload.properties?.requestID;
|
|
233
|
+
if (typeof permissionId === 'string') onPermissionReplied?.(permissionId);
|
|
221
234
|
}
|
|
222
235
|
};
|
|
223
236
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { createPermissionAutoAcceptRuntime } from './runtime.js';
|
|
3
3
|
|
|
4
|
-
const createRuntime = ({ stored, fetchImpl, retryDelaysMs = [0] } = {}) => {
|
|
4
|
+
const createRuntime = ({ stored, fetchImpl, retryDelaysMs = [0], evaluatePermission, onPermissionReplied } = {}) => {
|
|
5
5
|
let settings = stored ?? { permissionAutoAccept: { sessions: {} } };
|
|
6
6
|
let eventHandler;
|
|
7
7
|
let statusHandler;
|
|
@@ -16,6 +16,8 @@ const createRuntime = ({ stored, fetchImpl, retryDelaysMs = [0] } = {}) => {
|
|
|
16
16
|
persistSettings: async (changes) => { settings = { ...settings, ...changes }; },
|
|
17
17
|
fetchImpl: fetchImpl ?? vi.fn(async () => new Response('[]')),
|
|
18
18
|
retryDelaysMs,
|
|
19
|
+
evaluatePermission,
|
|
20
|
+
onPermissionReplied,
|
|
19
21
|
});
|
|
20
22
|
runtime.start();
|
|
21
23
|
return {
|
|
@@ -143,4 +145,38 @@ describe('permission auto-accept runtime', () => {
|
|
|
143
145
|
expect(fetchImpl.mock.calls.some(([url]) => new URL(url).searchParams.get('directory') === '/project')).toBe(true);
|
|
144
146
|
expect(await runtime.load()).toEqual({ sessions: { root: true }, revision: 1 });
|
|
145
147
|
});
|
|
148
|
+
|
|
149
|
+
it('leaves a request held by the safety net unanswered and forgets it once replied', async () => {
|
|
150
|
+
const fetchImpl = vi.fn(async () => new Response('[]'));
|
|
151
|
+
const verdicts = { held: { action: 'hold', score: 0.9, kind: 'git_history' }, safe: { action: 'accept', score: 0.1 } };
|
|
152
|
+
const evaluatePermission = vi.fn(async (permission) => verdicts[permission.id]);
|
|
153
|
+
const onPermissionReplied = vi.fn();
|
|
154
|
+
const { runtime, emit } = createRuntime({
|
|
155
|
+
stored: { permissionAutoAccept: { sessions: { root: true } } },
|
|
156
|
+
fetchImpl,
|
|
157
|
+
evaluatePermission,
|
|
158
|
+
onPermissionReplied,
|
|
159
|
+
});
|
|
160
|
+
await runtime.load();
|
|
161
|
+
|
|
162
|
+
emit({ type: 'permission.asked', properties: { id: 'held', sessionID: 'root', permission: 'bash', metadata: {} } });
|
|
163
|
+
emit({ type: 'permission.asked', properties: { id: 'safe', sessionID: 'root', permission: 'bash', metadata: {} } });
|
|
164
|
+
await flush();
|
|
165
|
+
|
|
166
|
+
const replies = fetchImpl.mock.calls.map(([url]) => String(url)).filter((url) => url.includes('/reply'));
|
|
167
|
+
expect(replies).toEqual(['http://opencode.test/permission/safe/reply?directory=%2Fproject']);
|
|
168
|
+
expect(evaluatePermission).toHaveBeenCalledTimes(2);
|
|
169
|
+
|
|
170
|
+
emit({ type: 'permission.replied', properties: { sessionID: 'root', requestID: 'held', reply: 'once' } });
|
|
171
|
+
expect(onPermissionReplied).toHaveBeenCalledWith('held');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not consult the safety net for sessions that are not auto-accepting', async () => {
|
|
175
|
+
const evaluatePermission = vi.fn(async () => ({ action: 'hold' }));
|
|
176
|
+
const { runtime, emit } = createRuntime({ evaluatePermission });
|
|
177
|
+
await runtime.load();
|
|
178
|
+
emit({ type: 'permission.asked', properties: { id: 'p', sessionID: 'manual', permission: 'bash', metadata: {} } });
|
|
179
|
+
await flush();
|
|
180
|
+
expect(evaluatePermission).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
146
182
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DateTime, IANAZone } from 'luxon';
|
|
2
|
-
import
|
|
2
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
3
3
|
|
|
4
4
|
import { projectConfigFileStemOf, projectPathFromId } from './project-id.js';
|
|
5
5
|
import {
|
|
@@ -145,7 +145,7 @@ const normalizeTimezone = (value, fallback = resolveDefaultTimezone()) => {
|
|
|
145
145
|
|
|
146
146
|
const validateCronExpression = (expression, timezone) => {
|
|
147
147
|
try {
|
|
148
|
-
const iterator =
|
|
148
|
+
const iterator = CronExpressionParser.parse(expression, {
|
|
149
149
|
tz: timezone,
|
|
150
150
|
currentDate: new Date(),
|
|
151
151
|
});
|
|
@@ -27,7 +27,6 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
|
|
27
27
|
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
|
28
28
|
| `command-code` | Command Code | `providers/command-code.js` | `command-code` OAuth/API credential in OpenCode `auth.json`, or `COMMAND_CODE_API_KEY` |
|
|
29
29
|
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
|
30
|
-
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
|
31
30
|
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
|
32
31
|
| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
|
|
33
32
|
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
|
@@ -11,7 +11,6 @@ import * as claude from './claude/index.js';
|
|
|
11
11
|
import * as clinePass from './cline-pass.js';
|
|
12
12
|
import * as codex from './codex.js';
|
|
13
13
|
import * as copilot from './copilot.js';
|
|
14
|
-
import * as crof from './crof.js';
|
|
15
14
|
import * as cursor from './cursor.js';
|
|
16
15
|
import * as deepseek from './deepseek.js';
|
|
17
16
|
import * as exeDev from './exe-dev.js';
|
|
@@ -50,12 +49,6 @@ const registry = {
|
|
|
50
49
|
isConfigured: codex.isConfigured,
|
|
51
50
|
fetchQuota: codex.fetchQuota
|
|
52
51
|
},
|
|
53
|
-
crof: {
|
|
54
|
-
providerId: crof.providerId,
|
|
55
|
-
providerName: crof.providerName,
|
|
56
|
-
isConfigured: crof.isConfigured,
|
|
57
|
-
fetchQuota: crof.fetchQuota
|
|
58
|
-
},
|
|
59
52
|
cursor: {
|
|
60
53
|
providerId: cursor.providerId,
|
|
61
54
|
providerName: cursor.providerName,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterAll, expect, it } from 'vitest';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { WebSocket } from 'ws';
|
|
9
|
+
|
|
10
|
+
import { attachRealtimeProxy, buildRealtimeProxyWsUrl } from './realtime-proxy.js';
|
|
11
|
+
import { createRequestSecurityRuntime } from './security/request-security.js';
|
|
12
|
+
import { createTerminalRuntime } from './terminal/runtime.js';
|
|
13
|
+
import { readTerminalWsControlFrame } from './terminal/terminal-ws-protocol.js';
|
|
14
|
+
|
|
15
|
+
const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
|
16
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-proxy-terminal-'));
|
|
17
|
+
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
|
18
|
+
const { createUiAuth } = await import('./ui-auth/ui-auth.js');
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
|
|
22
|
+
else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
|
23
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const listen = async (server) => {
|
|
27
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
28
|
+
return `http://127.0.0.1:${server.address().port}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const login = async (origin, password) => {
|
|
32
|
+
const response = await fetch(`${origin}/auth/session`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ password }),
|
|
36
|
+
});
|
|
37
|
+
expect(response.status).toBe(200);
|
|
38
|
+
const cookie = response.headers.get('set-cookie').split(';')[0];
|
|
39
|
+
const minted = await fetch(`${origin}/auth/url-token`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { Cookie: cookie, 'Content-Type': 'application/json' },
|
|
42
|
+
body: '{}',
|
|
43
|
+
});
|
|
44
|
+
expect(minted.status).toBe(200);
|
|
45
|
+
const { token } = await minted.json();
|
|
46
|
+
expect(token).toBeTruthy();
|
|
47
|
+
return token;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const connect = (url, origin = 'openchamber-ui://app') => new Promise((resolve, reject) => {
|
|
51
|
+
const socket = new WebSocket(url, { origin });
|
|
52
|
+
let hello = null;
|
|
53
|
+
let status = null;
|
|
54
|
+
const timeout = setTimeout(() => {
|
|
55
|
+
socket.terminate();
|
|
56
|
+
reject(new Error('Timed out waiting for terminal handshake'));
|
|
57
|
+
}, 3000);
|
|
58
|
+
socket.on('upgrade', (response) => { status = response.statusCode; });
|
|
59
|
+
socket.on('unexpected-response', (_request, response) => {
|
|
60
|
+
status = response.statusCode;
|
|
61
|
+
response.resume();
|
|
62
|
+
socket.terminate();
|
|
63
|
+
});
|
|
64
|
+
socket.on('message', (raw) => {
|
|
65
|
+
hello = readTerminalWsControlFrame(raw);
|
|
66
|
+
socket.close();
|
|
67
|
+
});
|
|
68
|
+
socket.on('error', () => {});
|
|
69
|
+
socket.on('close', (code) => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve({ hello, status, code });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('connects through the desktop proxy to an authenticated terminal without weakening either gate', async () => {
|
|
76
|
+
const password = crypto.randomBytes(24).toString('hex');
|
|
77
|
+
const remoteAuth = createUiAuth({ password });
|
|
78
|
+
const localAuth = createUiAuth({ password });
|
|
79
|
+
const remoteApp = express();
|
|
80
|
+
const localApp = express();
|
|
81
|
+
for (const [app, auth] of [[remoteApp, remoteAuth], [localApp, localAuth]]) {
|
|
82
|
+
app.use(express.json());
|
|
83
|
+
app.post('/auth/session', auth.handleSessionCreate);
|
|
84
|
+
app.post('/auth/url-token', auth.handleUrlAuthToken);
|
|
85
|
+
}
|
|
86
|
+
const remote = http.createServer(remoteApp);
|
|
87
|
+
const local = http.createServer(localApp);
|
|
88
|
+
const security = createRequestSecurityRuntime({ readSettingsFromDiskMigrated: async () => ({}) });
|
|
89
|
+
const received = [];
|
|
90
|
+
remote.on('upgrade', (req) => {
|
|
91
|
+
received.push({ origin: req.headers.origin, custom: req.headers['x-proxy-test'] });
|
|
92
|
+
});
|
|
93
|
+
const rejected = [];
|
|
94
|
+
const terminal = createTerminalRuntime({
|
|
95
|
+
app: remoteApp, server: remote, fs, path, uiAuthController: remoteAuth,
|
|
96
|
+
buildAugmentedPath: () => process.env.PATH || '',
|
|
97
|
+
searchPathFor: () => null,
|
|
98
|
+
isExecutable: () => false,
|
|
99
|
+
isRequestOriginAllowed: security.isRequestOriginAllowed,
|
|
100
|
+
rejectWebSocketUpgrade: (socket, status, reason) => {
|
|
101
|
+
rejected.push(status);
|
|
102
|
+
security.rejectWebSocketUpgrade(socket, status, reason);
|
|
103
|
+
},
|
|
104
|
+
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000,
|
|
105
|
+
});
|
|
106
|
+
let proxy;
|
|
107
|
+
try {
|
|
108
|
+
const remoteOrigin = await listen(remote);
|
|
109
|
+
const localOrigin = await listen(local);
|
|
110
|
+
proxy = attachRealtimeProxy({
|
|
111
|
+
app: localApp, server: local,
|
|
112
|
+
getDesktopRuntimeConfig: () => ({
|
|
113
|
+
apiBaseUrl: remoteOrigin,
|
|
114
|
+
requestHeaders: { 'X-Proxy-Test': 'present' },
|
|
115
|
+
}),
|
|
116
|
+
getUiAuthController: () => localAuth,
|
|
117
|
+
isRequestOriginAllowed: security.isRequestOriginAllowed,
|
|
118
|
+
});
|
|
119
|
+
const remoteToken = await login(remoteOrigin, password);
|
|
120
|
+
const localToken = await login(localOrigin, password);
|
|
121
|
+
const target = new URL('/api/terminal/ws', remoteOrigin);
|
|
122
|
+
target.protocol = 'ws:';
|
|
123
|
+
target.searchParams.set('oc_url_token', remoteToken);
|
|
124
|
+
const proxied = new URL(buildRealtimeProxyWsUrl(localOrigin, target.toString()));
|
|
125
|
+
proxied.searchParams.set('oc_url_token', localToken);
|
|
126
|
+
|
|
127
|
+
expect((await connect(target.toString())).hello).toEqual({ t: 'hello', v: 3 });
|
|
128
|
+
expect((await connect(proxied.toString())).hello).toEqual({ t: 'hello', v: 3 });
|
|
129
|
+
expect(received.at(-1)).toEqual({ origin: remoteOrigin, custom: 'present' });
|
|
130
|
+
expect(rejected).toEqual([]);
|
|
131
|
+
|
|
132
|
+
const beforeRejectedLocal = received.length;
|
|
133
|
+
expect((await connect(proxied.toString(), 'https://untrusted.example')).status).toBe(403);
|
|
134
|
+
const unauthenticatedLocal = new URL(proxied);
|
|
135
|
+
unauthenticatedLocal.searchParams.delete('oc_url_token');
|
|
136
|
+
expect((await connect(unauthenticatedLocal.toString())).status).toBe(401);
|
|
137
|
+
expect(received).toHaveLength(beforeRejectedLocal);
|
|
138
|
+
|
|
139
|
+
target.searchParams.delete('oc_url_token');
|
|
140
|
+
proxied.searchParams.set('url', target.toString());
|
|
141
|
+
expect((await connect(proxied.toString())).hello).toBeNull();
|
|
142
|
+
expect(rejected).toEqual([401]);
|
|
143
|
+
} finally {
|
|
144
|
+
proxy?.stop();
|
|
145
|
+
await terminal.shutdown();
|
|
146
|
+
remoteAuth.dispose();
|
|
147
|
+
localAuth.dispose();
|
|
148
|
+
await Promise.all([remote, local].map((server) => new Promise((resolve) => server.close(resolve))));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -200,6 +200,8 @@ export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getU
|
|
|
200
200
|
|
|
201
201
|
const upstream = new WebSocket(resolved.target.toString(), {
|
|
202
202
|
headers: resolved.requestHeaders,
|
|
203
|
+
// ws does not send Origin by default; protected upstreams require it.
|
|
204
|
+
origin: `${resolved.target.protocol === 'wss:' ? 'https:' : 'http:'}//${resolved.target.host}`,
|
|
203
205
|
});
|
|
204
206
|
const pending = [];
|
|
205
207
|
|