@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
|
@@ -134,6 +134,7 @@ function createRuntime(server, overrides = {}) {
|
|
|
134
134
|
isExecutable: () => false,
|
|
135
135
|
isRequestOriginAllowed: async () => true,
|
|
136
136
|
rejectWebSocketUpgrade() {},
|
|
137
|
+
shutdownProcesses: async terminals => { for (const terminal of terminals) terminal.process.kill('SIGKILL'); },
|
|
137
138
|
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000,
|
|
138
139
|
TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1_000,
|
|
139
140
|
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: 3,
|
|
@@ -210,6 +211,72 @@ describe('terminal runtime', () => {
|
|
|
210
211
|
} finally { await harness.runtime.shutdown(); }
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
it('reaps a pending create during shutdown and rejects later creates', async () => {
|
|
215
|
+
const gate = deferred();
|
|
216
|
+
const harness = createHarness({ spawnDeferred: gate });
|
|
217
|
+
const create = harness.routes.post.get('/api/terminal/create');
|
|
218
|
+
const response = createResponse();
|
|
219
|
+
const creation = create({ body: { sessionId: 'pending', cwd: '/repo' } }, response);
|
|
220
|
+
const closing = harness.runtime.shutdown();
|
|
221
|
+
gate.resolve();
|
|
222
|
+
await Promise.all([creation, closing]);
|
|
223
|
+
expect(harness.processes).toHaveLength(1);
|
|
224
|
+
expect(harness.processes[0].killed).toBe(true);
|
|
225
|
+
expect(response.statusCode).toBe(400);
|
|
226
|
+
const later = createResponse();
|
|
227
|
+
await create({ body: { sessionId: 'later', cwd: '/repo' } }, later);
|
|
228
|
+
expect(later.statusCode).toBe(400);
|
|
229
|
+
expect(harness.processes).toHaveLength(1);
|
|
230
|
+
await harness.runtime.shutdown();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('joins terminal cleanup and retires sessions before waiting for shutdown', async () => {
|
|
234
|
+
const gate = deferred();
|
|
235
|
+
let terminals;
|
|
236
|
+
const harness = createHarness({ shutdownProcesses: async current => { terminals = current; await gate.promise; } });
|
|
237
|
+
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'running', cwd: '/repo' } }, createResponse());
|
|
238
|
+
let done = false;
|
|
239
|
+
const closing = harness.runtime.shutdown();
|
|
240
|
+
expect(harness.runtime.shutdown()).toBe(closing);
|
|
241
|
+
closing.then(() => { done = true; });
|
|
242
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
243
|
+
expect(terminals).toHaveLength(1);
|
|
244
|
+
expect(terminals[0].process).toBe(harness.processes[0]);
|
|
245
|
+
expect(done).toBe(false);
|
|
246
|
+
const later = createResponse();
|
|
247
|
+
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'later', cwd: '/repo' } }, later);
|
|
248
|
+
expect(later.statusCode).toBe(400);
|
|
249
|
+
gate.resolve();
|
|
250
|
+
await closing;
|
|
251
|
+
expect(done).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
for (const removal of ['close', 'force-kill']) {
|
|
255
|
+
it(`reaps a replacement PTY when ${removal} wins a pending restart`, async () => {
|
|
256
|
+
const gate = { promise: Promise.resolve() };
|
|
257
|
+
const harness = createHarness({ spawnDeferred: gate });
|
|
258
|
+
try {
|
|
259
|
+
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'terminal', cwd: '/repo' } }, createResponse());
|
|
260
|
+
const replacement = deferred();
|
|
261
|
+
gate.promise = replacement.promise;
|
|
262
|
+
const response = createResponse();
|
|
263
|
+
const restarting = harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'terminal' }, body: {} }, response);
|
|
264
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
265
|
+
const removed = createResponse();
|
|
266
|
+
if (removal === 'close') {
|
|
267
|
+
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'terminal' } }, removed);
|
|
268
|
+
} else {
|
|
269
|
+
harness.routes.post.get('/api/terminal/force-kill')({ body: { sessionId: 'terminal' } }, removed);
|
|
270
|
+
}
|
|
271
|
+
replacement.resolve();
|
|
272
|
+
await restarting;
|
|
273
|
+
expect(harness.processes).toHaveLength(2);
|
|
274
|
+
expect(harness.processes.every((child) => child.killed)).toBe(true);
|
|
275
|
+
expect(response.statusCode).toBe(400);
|
|
276
|
+
} finally { await harness.runtime.shutdown(); }
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
213
280
|
it('retains completed output when the replacement command fails to start', async () => {
|
|
214
281
|
let available = true;
|
|
215
282
|
const harness = createHarness({ fs: { promises: { stat: async () => ({ isDirectory: () => available }) } } });
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const SHUTDOWN_GRACE_MS = 20_000;
|
|
8
|
+
|
|
9
|
+
// Job-control shells give foreground/background jobs their own process groups.
|
|
10
|
+
// Killing only -pty.pid misses those jobs; closing the PTY first sends SIGHUP.
|
|
11
|
+
const readProcesses = async () => {
|
|
12
|
+
// Minimal server/container installations need not ship procps.
|
|
13
|
+
if (process.platform === 'linux') {
|
|
14
|
+
const processes = new Map();
|
|
15
|
+
for (const entry of await fs.readdir('/proc')) {
|
|
16
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
17
|
+
let stat;
|
|
18
|
+
try { stat = await fs.readFile(`/proc/${entry}/stat`, 'utf8'); }
|
|
19
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ESRCH') continue; throw error; }
|
|
20
|
+
const end = stat.lastIndexOf(')');
|
|
21
|
+
const fields = stat.slice(end + 2).trim().split(/\s+/);
|
|
22
|
+
const pid = Number(entry);
|
|
23
|
+
processes.set(pid, { pid, parent: Number(fields[1]), group: Number(fields[2]), zombie: fields[0] === 'Z', started: fields[19], command: stat.slice(stat.indexOf('(') + 1, end) });
|
|
24
|
+
}
|
|
25
|
+
return processes;
|
|
26
|
+
}
|
|
27
|
+
const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid=,pgid=,stat=,lstart=,comm='], { timeout: 1000, maxBuffer: 4 * 1024 * 1024 });
|
|
28
|
+
return new Map(stdout.trim().split('\n').map(line => {
|
|
29
|
+
const fields = line.trim().split(/\s+/);
|
|
30
|
+
return [Number(fields[0]), { pid: Number(fields[0]), parent: Number(fields[1]), group: Number(fields[2]), zombie: fields[3].startsWith('Z'), started: fields.slice(4, 9).join(' '), command: fields.slice(9).join(' ') }];
|
|
31
|
+
}));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const signal = (pid, name) => {
|
|
35
|
+
try { process.kill(pid, name); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function shutdownTerminalProcesses(terminals, { graceMs = SHUTDOWN_GRACE_MS } = {}) {
|
|
39
|
+
if (!terminals.length) return;
|
|
40
|
+
// ConPTY has no POSIX signal protocol. Its backend owns tree teardown.
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
for (const { process: pty } of terminals) {
|
|
43
|
+
try { pty.kill(); } catch { /* already gone; other terminals still need cleanup */ }
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const deadline = Date.now() + graceMs;
|
|
49
|
+
const owned = new Map();
|
|
50
|
+
const shells = new Map();
|
|
51
|
+
try {
|
|
52
|
+
let snapshot = await readProcesses();
|
|
53
|
+
for (const terminal of terminals) {
|
|
54
|
+
const current = snapshot.get(terminal.process.pid);
|
|
55
|
+
if (!current || current.zombie) continue;
|
|
56
|
+
owned.set(current.pid, { ...current, terminal: current.pid });
|
|
57
|
+
// An exec'ed server occupies the shell PID, but must get the same grace
|
|
58
|
+
// as a child. Only an actual shell is held open until its jobs finish.
|
|
59
|
+
if (path.basename(current.command) === path.basename(terminal.shellExecutable)) shells.set(current.pid, terminal.process);
|
|
60
|
+
}
|
|
61
|
+
const signalled = new Set();
|
|
62
|
+
while (owned.size) {
|
|
63
|
+
for (const [pid, previous] of owned) {
|
|
64
|
+
const current = snapshot.get(pid);
|
|
65
|
+
if (!current || current.zombie || current.started !== previous.started) { owned.delete(pid); shells.delete(pid); }
|
|
66
|
+
else if (current.command !== previous.command) shells.delete(pid);
|
|
67
|
+
}
|
|
68
|
+
// Retain descendants before signalling anything, including separate job
|
|
69
|
+
// groups and children still cleaning up after their parent has exited.
|
|
70
|
+
let added;
|
|
71
|
+
do {
|
|
72
|
+
added = false;
|
|
73
|
+
for (const current of snapshot.values()) {
|
|
74
|
+
if (!current.zombie && !owned.has(current.pid) && owned.has(current.parent)) {
|
|
75
|
+
owned.set(current.pid, { ...current, terminal: owned.get(current.parent).terminal });
|
|
76
|
+
added = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} while (added);
|
|
80
|
+
for (const pid of owned.keys()) {
|
|
81
|
+
if (!shells.has(pid) && !signalled.has(pid)) { signal(pid, 'SIGTERM'); signalled.add(pid); }
|
|
82
|
+
}
|
|
83
|
+
for (const [pid, pty] of shells) {
|
|
84
|
+
const hasJobs = [...owned.values()].some(current => current.terminal === pid && current.pid !== pid);
|
|
85
|
+
if (!hasJobs && !signalled.has(pid)) { pty.kill('SIGHUP'); signalled.add(pid); }
|
|
86
|
+
}
|
|
87
|
+
if (!owned.size) return;
|
|
88
|
+
if (Date.now() >= deadline) {
|
|
89
|
+
for (const current of owned.values()) {
|
|
90
|
+
if (current.group === current.pid) signal(-current.group, 'SIGKILL');
|
|
91
|
+
signal(current.pid, 'SIGKILL');
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
96
|
+
snapshot = await readProcesses();
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A failed process-table read is not evidence that the jobs exited.
|
|
100
|
+
console.warn('Terminal graceful shutdown failed; forcing owned PTYs closed:', error.message);
|
|
101
|
+
for (const pid of owned.keys()) { try { signal(pid, 'SIGKILL'); } catch { /* best effort */ } }
|
|
102
|
+
for (const { process: pty } of terminals) {
|
|
103
|
+
try { pty.kill('SIGKILL'); } catch { /* continue with the remaining PTYs */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { test } from 'vitest';
|
|
7
|
+
import { shutdownTerminalProcesses } from './shutdown.js';
|
|
8
|
+
|
|
9
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
const script = `
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const [file, mode] = process.argv.slice(1);
|
|
13
|
+
const record = event => fs.appendFileSync(file, JSON.stringify({ pid: process.pid, event }) + '\\n');
|
|
14
|
+
process.on('SIGHUP', () => record('SIGHUP'));
|
|
15
|
+
process.on('SIGTERM', () => {
|
|
16
|
+
record('SIGTERM');
|
|
17
|
+
if (mode === 'ignore') return;
|
|
18
|
+
if (mode === 'parent') return process.exit(0);
|
|
19
|
+
setTimeout(() => { record('complete'); process.exit(0); }, 250);
|
|
20
|
+
});
|
|
21
|
+
record('ready');
|
|
22
|
+
setInterval(() => {}, 1000);
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
async function fixture(t, mode) {
|
|
26
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'terminal-shutdown-'));
|
|
27
|
+
const file = path.join(dir, 'events.jsonl');
|
|
28
|
+
const source = script + (mode === 'parent' ? `require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(script)}, file, 'worker'], { detached: true, stdio: 'ignore' }).unref();` : '');
|
|
29
|
+
const child = spawn(process.execPath, ['-e', source, file, mode], { detached: true, stdio: 'ignore' });
|
|
30
|
+
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
|
|
31
|
+
const events = async () => { try { return (await fs.readFile(file, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } };
|
|
32
|
+
t.onTestFinished(async () => {
|
|
33
|
+
for (const pid of new Set([child.pid, ...(await events()).map(event => event.pid)])) {
|
|
34
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
|
35
|
+
}
|
|
36
|
+
await exit;
|
|
37
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
const count = mode === 'parent' ? 2 : 1;
|
|
40
|
+
for (let i = 0; i < 100 && (await events()).filter(event => event.event === 'ready').length < count; i++) await delay(20);
|
|
41
|
+
assert.equal((await events()).filter(event => event.event === 'ready').length, count);
|
|
42
|
+
return { terminal: { process: { pid: child.pid, kill: signal => child.kill(signal) }, shellExecutable: '/bin/sh' }, events, exit };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('graceful shutdown lets concurrent exec workloads finish without SIGHUP or SIGKILL', { skip: process.platform === 'win32' }, async t => {
|
|
46
|
+
const first = await fixture(t, 'worker');
|
|
47
|
+
const second = await fixture(t, 'worker');
|
|
48
|
+
await shutdownTerminalProcesses([first.terminal, second.terminal], { graceMs: 1500 });
|
|
49
|
+
for (const run of [first, second]) {
|
|
50
|
+
assert.deepEqual((await run.events()).map(event => event.event), ['ready', 'SIGTERM', 'complete']);
|
|
51
|
+
assert.deepEqual(await run.exit, { code: 0, signal: null });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('waits for a separate process group after its parent exits', { skip: process.platform === 'win32' }, async t => {
|
|
56
|
+
const run = await fixture(t, 'parent');
|
|
57
|
+
await shutdownTerminalProcesses([run.terminal], { graceMs: 1500 });
|
|
58
|
+
const events = await run.events();
|
|
59
|
+
assert.equal(events.filter(event => event.event === 'SIGTERM').length, 2);
|
|
60
|
+
assert.equal(events.filter(event => event.event === 'complete').length, 1);
|
|
61
|
+
assert.deepEqual(await run.exit, { code: 0, signal: null });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('escalates a noncooperative workload only after the shared deadline', { skip: process.platform === 'win32' }, async t => {
|
|
65
|
+
const run = await fixture(t, 'ignore');
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
await shutdownTerminalProcesses([run.terminal], { graceMs: 350 });
|
|
68
|
+
assert.ok(Date.now() - start >= 350);
|
|
69
|
+
assert.deepEqual((await run.events()).map(event => event.event), ['ready', 'SIGTERM']);
|
|
70
|
+
assert.deepEqual(await run.exit, { code: null, signal: 'SIGKILL' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('an idle shell closes promptly and shutdown waits for its exit', { skip: process.platform === 'win32' }, async t => {
|
|
74
|
+
const child = spawn('/bin/sh', ['-i'], { detached: true, stdio: 'pipe' });
|
|
75
|
+
child.stdout.resume();
|
|
76
|
+
child.stderr.resume();
|
|
77
|
+
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
|
|
78
|
+
t.onTestFinished(async () => { child.kill('SIGKILL'); await exit; });
|
|
79
|
+
await delay(100);
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
await shutdownTerminalProcesses([{ process: { pid: child.pid, kill: signal => child.kill(signal) }, shellExecutable: '/bin/sh' }]);
|
|
82
|
+
assert.ok(Date.now() - start < 3000);
|
|
83
|
+
assert.equal((await exit).signal, 'SIGHUP');
|
|
84
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { TTSService } from './service.js';
|
|
5
|
+
import { transcribeAudio } from './stt.js';
|
|
6
|
+
|
|
7
|
+
describe('OpenAI audio SDK compatibility', () => {
|
|
8
|
+
let server;
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (server) {
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
14
|
+
server.closeAllConnections();
|
|
15
|
+
});
|
|
16
|
+
server = undefined;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const listen = async (handler) => {
|
|
21
|
+
server = createServer(handler);
|
|
22
|
+
server.listen(0, '127.0.0.1');
|
|
23
|
+
await once(server, 'listening');
|
|
24
|
+
return `http://127.0.0.1:${server.address().port}/v1`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it('sends custom speech options and returns the audio bytes', async () => {
|
|
28
|
+
const audio = Buffer.from('speech-response');
|
|
29
|
+
let received;
|
|
30
|
+
const baseURL = await listen(async (request, response) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
33
|
+
received = { url: request.url, method: request.method, body: JSON.parse(Buffer.concat(chunks).toString()) };
|
|
34
|
+
response.writeHead(200, { 'Content-Type': 'audio/mpeg' });
|
|
35
|
+
response.end(audio);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const result = await new TTSService().generateSpeechStream({
|
|
39
|
+
text: 'Hello', model: 'custom-tts', voice: 'coral', speed: 1.2, baseURL,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(received).toEqual({
|
|
43
|
+
url: '/v1/audio/speech', method: 'POST',
|
|
44
|
+
body: { input: 'Hello', model: 'custom-tts', voice: 'coral', speed: 1.2 },
|
|
45
|
+
});
|
|
46
|
+
expect(result).toEqual({ buffer: audio, contentType: 'audio/mpeg' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uploads transcription audio as multipart data and reads the text', async () => {
|
|
50
|
+
const audio = Buffer.from('transcription-input');
|
|
51
|
+
let received;
|
|
52
|
+
const baseURL = await listen(async (request, response) => {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
55
|
+
const form = await new Response(Buffer.concat(chunks), {
|
|
56
|
+
headers: { 'Content-Type': request.headers['content-type'] },
|
|
57
|
+
}).formData();
|
|
58
|
+
const file = form.get('file');
|
|
59
|
+
received = {
|
|
60
|
+
url: request.url, method: request.method,
|
|
61
|
+
model: form.get('model'), language: form.get('language'), format: form.get('response_format'),
|
|
62
|
+
filename: file.name, audio: Buffer.from(await file.arrayBuffer()),
|
|
63
|
+
};
|
|
64
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
65
|
+
response.end(JSON.stringify({ text: 'Hello from transcription' }));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await transcribeAudio({
|
|
69
|
+
audioBuffer: audio, mimeType: 'audio/webm', model: 'whisper-1', language: 'en', baseURL,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(received).toEqual({
|
|
73
|
+
url: '/v1/audio/transcriptions', method: 'POST', model: 'whisper-1', language: 'en',
|
|
74
|
+
format: 'json', filename: 'audio.webm', audio,
|
|
75
|
+
});
|
|
76
|
+
expect(result).toBe('Hello from transcription');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -10,9 +10,17 @@ Pairing v2 is implemented by `packages/web/server/lib/client-auth/pairing.js`. I
|
|
|
10
10
|
## Entrypoints and structure
|
|
11
11
|
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers.
|
|
12
12
|
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers.
|
|
13
|
+
- `packages/web/server/lib/ui-auth/session-cookie.js`: resolves the session cookie name from a request's `Host` port; shared by session issuance and validation in `ui-auth.js` and notification/session identity extraction in `packages/web/server/lib/security/request-security.js`.
|
|
13
14
|
- `packages/web/server/lib/client-auth/remote-clients.js`: trusted-device client token storage, bearer authentication, last-used tracking, and revocation.
|
|
14
15
|
- `packages/web/server/lib/client-auth/pairing.js`: short-lived Pairing v2 sessions and one-time secret redemption into trusted-device client tokens.
|
|
15
16
|
|
|
17
|
+
## Session cookie scoping
|
|
18
|
+
Browsers key a cookie jar on the host only, never the port (RFC 6265). Two OpenChamber instances reached through the same hostname on different ports therefore shared a single `oc_ui_session` cookie. Logging into the second overwrote the first instance's session cookie (issue #2377). This affects LAN and loopback hosts alike.
|
|
19
|
+
|
|
20
|
+
`sessionCookieNameForRequest(req, base)` folds the request `Host` port into the cookie name: `oc_ui_session_<port>` when the host carries an explicit port (including a port from `x-forwarded-host`), or the bare `oc_ui_session` when it does not. `localhost:3000` and `localhost:3001` use separate names, just like two ports on one LAN address. Password and browser passkey login both call `issueSession` and receive this cookie. Session validation, clearing, and notification identity extraction use the same resolver; identity extraction does not provide a CSRF token. Bearer-token validation is unchanged.
|
|
21
|
+
|
|
22
|
+
Compatibility: upgrading renames the cookie for any explicit-port host, so already-signed-in browser sessions must log in once again. No on-disk format changes.
|
|
23
|
+
|
|
16
24
|
## Public exports (ui-auth.js)
|
|
17
25
|
- `createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })`: creates UI auth controller with methods:
|
|
18
26
|
- `enabled`
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session cookie naming that is stable per host:port.
|
|
3
|
+
*
|
|
4
|
+
* Browsers do not isolate cookies by port (RFC 6265). Instances on the same
|
|
5
|
+
* hostname overwrite one another's session cookies without distinct names.
|
|
6
|
+
* This applies to LAN addresses and same-host loopback instances alike.
|
|
7
|
+
*
|
|
8
|
+
* We fold the request port into the cookie name so each port owns its own
|
|
9
|
+
* cookie. Password/passkey session issuance and validation (`ui-auth.js`) and
|
|
10
|
+
* notification identity extraction (`request-security.js`) share this resolver.
|
|
11
|
+
* A host with no explicit port keeps the bare `oc_ui_session` name.
|
|
12
|
+
*/
|
|
13
|
+
export const SESSION_COOKIE_BASE = 'oc_ui_session';
|
|
14
|
+
|
|
15
|
+
const forwardedHost = (headers) => {
|
|
16
|
+
const forwarded = headers?.['x-forwarded-host'];
|
|
17
|
+
if (typeof forwarded === 'string' && forwarded.trim().length > 0) {
|
|
18
|
+
return forwarded.split(',')[0].trim();
|
|
19
|
+
}
|
|
20
|
+
const host = headers?.host;
|
|
21
|
+
return typeof host === 'string' ? host.trim() : '';
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the trailing port from a Host authority, or null when there is none.
|
|
26
|
+
* Handles bracketed IPv6 (`[::1]:3000`, `[::1]`) and plain host names.
|
|
27
|
+
*/
|
|
28
|
+
const hostPort = (host) => {
|
|
29
|
+
if (host.startsWith('[')) {
|
|
30
|
+
const end = host.indexOf(']');
|
|
31
|
+
if (end === -1) return null;
|
|
32
|
+
const rest = host.slice(end + 1);
|
|
33
|
+
if (!rest.startsWith(':')) return null;
|
|
34
|
+
return /^\d+$/.test(rest.slice(1)) ? rest.slice(1) : null;
|
|
35
|
+
}
|
|
36
|
+
const matches = host.match(/:(\d+)$/);
|
|
37
|
+
return matches ? matches[1] : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the session cookie name for a request. Returns the bare base name
|
|
42
|
+
* when the host carries no explicit port, otherwise `<base>_<port>`.
|
|
43
|
+
*/
|
|
44
|
+
export const sessionCookieNameForRequest = (req, base = SESSION_COOKIE_BASE) => {
|
|
45
|
+
const host = forwardedHost(req?.headers || {});
|
|
46
|
+
const port = host ? hostPort(host) : null;
|
|
47
|
+
if (!port) return base;
|
|
48
|
+
const portNumber = Number.parseInt(port, 10);
|
|
49
|
+
if (!Number.isFinite(portNumber) || portNumber <= 0) return base;
|
|
50
|
+
return `${base}_${port}`;
|
|
51
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
SESSION_COOKIE_BASE,
|
|
4
|
+
sessionCookieNameForRequest,
|
|
5
|
+
} from './session-cookie.js';
|
|
6
|
+
|
|
7
|
+
const req = (host, forwarded) => ({
|
|
8
|
+
headers: {
|
|
9
|
+
...(host ? { host } : {}),
|
|
10
|
+
...(forwarded ? { 'x-forwarded-host': forwarded } : {}),
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('sessionCookieNameForRequest', () => {
|
|
15
|
+
test('keeps the bare base name when the host has no explicit port', () => {
|
|
16
|
+
expect(sessionCookieNameForRequest(req('192.168.0.1'))).toBe(SESSION_COOKIE_BASE);
|
|
17
|
+
expect(sessionCookieNameForRequest(req('example.com'))).toBe(SESSION_COOKIE_BASE);
|
|
18
|
+
expect(sessionCookieNameForRequest(req(''))).toBe(SESSION_COOKIE_BASE);
|
|
19
|
+
expect(sessionCookieNameForRequest(undefined)).toBe(SESSION_COOKIE_BASE);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('folds the port into the name so LAN instances on one IP are isolated', () => {
|
|
23
|
+
expect(sessionCookieNameForRequest(req('192.168.0.1:3000'))).toBe('oc_ui_session_3000');
|
|
24
|
+
expect(sessionCookieNameForRequest(req('192.168.0.1:3001'))).toBe('oc_ui_session_3001');
|
|
25
|
+
expect(sessionCookieNameForRequest(req('192.168.0.1:8080'))).toBe('oc_ui_session_8080');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('handles IPv6 authorities', () => {
|
|
29
|
+
expect(sessionCookieNameForRequest(req('[::1]:3000'))).toBe('oc_ui_session_3000');
|
|
30
|
+
expect(sessionCookieNameForRequest(req('[::1]'))).toBe(SESSION_COOKIE_BASE);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('prefers the forwarded host port over the direct host', () => {
|
|
34
|
+
expect(sessionCookieNameForRequest(req('127.0.0.1:3902', '203.0.113.9:8443')))
|
|
35
|
+
.toBe('oc_ui_session_8443');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('ignores malformed or non-numeric ports', () => {
|
|
39
|
+
expect(sessionCookieNameForRequest(req('192.168.0.1:notaport'))).toBe(SESSION_COOKIE_BASE);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('honours a custom base name', () => {
|
|
43
|
+
expect(sessionCookieNameForRequest(req('127.0.0.1:3000'), 'my_app')).toBe('my_app_3000');
|
|
44
|
+
expect(sessionCookieNameForRequest(req('127.0.0.1'), 'my_app')).toBe('my_app');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -4,6 +4,7 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import { createUiPasskeys } from './ui-passkeys.js';
|
|
7
|
+
import { sessionCookieNameForRequest } from './session-cookie.js';
|
|
7
8
|
|
|
8
9
|
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
|
9
10
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
@@ -291,6 +292,25 @@ const isWebSocketUpgrade = (req) => {
|
|
|
291
292
|
return String(upgradeValue || '').toLowerCase() === 'websocket';
|
|
292
293
|
};
|
|
293
294
|
|
|
295
|
+
const GUEST_URL_AUTH_SCOPE = /^guest:([a-z][a-z0-9-]*)$/;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A URL token scope narrows where the token is accepted. `guest:<id>` is
|
|
299
|
+
* minted for a guest iframe and only opens that guest's own package files:
|
|
300
|
+
* the iframe URL is readable by the guest's script, so the token must be
|
|
301
|
+
* worthless anywhere else.
|
|
302
|
+
* @returns {{ kind: 'guest', id: string } | null | undefined} `undefined` when the value is not a scope
|
|
303
|
+
*/
|
|
304
|
+
const parseUrlAuthScope = (value) => {
|
|
305
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
306
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
307
|
+
if (typeof raw !== 'string') return undefined;
|
|
308
|
+
const match = raw.trim().match(GUEST_URL_AUTH_SCOPE);
|
|
309
|
+
return match ? { kind: 'guest', id: match[1] } : undefined;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const isGuestScopedPath = (pathname, guestId) => pathname.startsWith(`/api/guests/${guestId}/`);
|
|
313
|
+
|
|
294
314
|
const isUrlAuthReadableHttpPath = (pathname) => {
|
|
295
315
|
return pathname === '/api/event'
|
|
296
316
|
|| pathname === '/api/global/event'
|
|
@@ -301,7 +321,9 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
|
|
301
321
|
|| pathname === '/api/fs/serve'
|
|
302
322
|
|| pathname.startsWith('/api/fs/serve/')
|
|
303
323
|
|| pathname.startsWith('/api/preview/proxy/')
|
|
304
|
-
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname)
|
|
324
|
+
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname)
|
|
325
|
+
|| pathname === '/api/guests'
|
|
326
|
+
|| /^\/api\/guests\/[a-z][a-z0-9-]*\//.test(pathname);
|
|
305
327
|
};
|
|
306
328
|
|
|
307
329
|
const isUrlAuthWebSocketPath = (pathname) => {
|
|
@@ -314,9 +336,12 @@ const isUrlAuthWebSocketPath = (pathname) => {
|
|
|
314
336
|
|| pathname.startsWith('/api/preview/proxy/');
|
|
315
337
|
};
|
|
316
338
|
|
|
317
|
-
const canUseUrlAuthTokenForRequest = (req) => {
|
|
339
|
+
const canUseUrlAuthTokenForRequest = (req, scope = null) => {
|
|
318
340
|
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
319
341
|
const pathname = getRequestPathname(req);
|
|
342
|
+
if (scope?.kind === 'guest') {
|
|
343
|
+
return !isWebSocketUpgrade(req) && method === 'GET' && isGuestScopedPath(pathname, scope.id);
|
|
344
|
+
}
|
|
320
345
|
if (isWebSocketUpgrade(req)) {
|
|
321
346
|
return isUrlAuthWebSocketPath(pathname);
|
|
322
347
|
}
|
|
@@ -425,16 +450,15 @@ export const createUiAuth = ({
|
|
|
425
450
|
}
|
|
426
451
|
};
|
|
427
452
|
|
|
428
|
-
const issueUrlAuthTokenForSession = (sessionToken) => {
|
|
453
|
+
const issueUrlAuthTokenForSession = (sessionToken, scope = null) => {
|
|
429
454
|
sweepUrlAuthTokens();
|
|
430
455
|
const token = `${URL_AUTH_TOKEN_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
|
|
431
456
|
const expiresAt = Date.now() + URL_AUTH_TOKEN_TTL_MS;
|
|
432
|
-
urlAuthTokens.set(token, { sessionToken, expiresAt });
|
|
457
|
+
urlAuthTokens.set(token, { sessionToken, expiresAt, scope });
|
|
433
458
|
return { token, expiresAt };
|
|
434
459
|
};
|
|
435
460
|
|
|
436
461
|
const authenticateUrlAuthToken = (req) => {
|
|
437
|
-
if (!canUseUrlAuthTokenForRequest(req)) return null;
|
|
438
462
|
const token = getUrlAuthTokenFromRequest(req);
|
|
439
463
|
if (!token || !token.startsWith(URL_AUTH_TOKEN_PREFIX)) return null;
|
|
440
464
|
const entry = urlAuthTokens.get(token);
|
|
@@ -442,9 +466,13 @@ export const createUiAuth = ({
|
|
|
442
466
|
urlAuthTokens.delete(token);
|
|
443
467
|
return null;
|
|
444
468
|
}
|
|
469
|
+
if (!canUseUrlAuthTokenForRequest(req, entry.scope)) return null;
|
|
445
470
|
return { ok: true, sessionToken: entry.sessionToken || 'url:authenticated' };
|
|
446
471
|
};
|
|
447
472
|
|
|
473
|
+
/** Scope requested on `POST /auth/url-token`: `?scope=guest:<id>` or `{ scope }` in the body. */
|
|
474
|
+
const readRequestedUrlAuthScope = (req) => parseUrlAuthScope(req?.body?.scope ?? req?.query?.scope);
|
|
475
|
+
|
|
448
476
|
const authenticateClientRequest = async (req, { allowUrlToken = true } = {}) => {
|
|
449
477
|
if (allowUrlToken) {
|
|
450
478
|
const urlAuth = authenticateUrlAuthToken(req);
|
|
@@ -489,7 +517,7 @@ export const createUiAuth = ({
|
|
|
489
517
|
const secure = isSecureRequest(req);
|
|
490
518
|
const maxAgeSeconds = Math.floor(ttlMs / 1000);
|
|
491
519
|
const header = buildCookie({
|
|
492
|
-
name: cookieName,
|
|
520
|
+
name: sessionCookieNameForRequest(req, cookieName),
|
|
493
521
|
value: encodeURIComponent(token),
|
|
494
522
|
maxAge: maxAgeSeconds,
|
|
495
523
|
secure,
|
|
@@ -499,8 +527,9 @@ export const createUiAuth = ({
|
|
|
499
527
|
|
|
500
528
|
const ensureSessionToken = async (req, res) => {
|
|
501
529
|
const cookies = parseCookies(req.headers.cookie);
|
|
502
|
-
|
|
503
|
-
|
|
530
|
+
const name = sessionCookieNameForRequest(req, cookieName);
|
|
531
|
+
if (cookies[name]) {
|
|
532
|
+
return cookies[name];
|
|
504
533
|
}
|
|
505
534
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
506
535
|
setSessionCookie(req, res, token, sessionTtlMs);
|
|
@@ -533,8 +562,9 @@ export const createUiAuth = ({
|
|
|
533
562
|
|
|
534
563
|
const resolveAuthContext = async (req, res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
|
|
535
564
|
const cookies = parseCookies(req.headers.cookie);
|
|
536
|
-
|
|
537
|
-
|
|
565
|
+
const name = sessionCookieNameForRequest(req, cookieName);
|
|
566
|
+
if (cookies[name]) {
|
|
567
|
+
return { type: 'session', token: cookies[name] };
|
|
538
568
|
}
|
|
539
569
|
if (allowClientAuth) {
|
|
540
570
|
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
|
@@ -566,17 +596,21 @@ export const createUiAuth = ({
|
|
|
566
596
|
res.status(400).json({ error: 'UI password not configured' });
|
|
567
597
|
},
|
|
568
598
|
handleUrlAuthToken: async (req, res) => {
|
|
599
|
+
const scope = readRequestedUrlAuthScope(req);
|
|
600
|
+
if (scope === undefined) {
|
|
601
|
+
return res.status(400).json({ error: 'Unknown URL token scope' });
|
|
602
|
+
}
|
|
569
603
|
const clientAuth = await authenticateClientRequest(req, { allowUrlToken: false });
|
|
570
604
|
if (clientAuth) {
|
|
571
605
|
res.setHeader('Cache-Control', 'no-store');
|
|
572
|
-
return res.json(issueUrlAuthTokenForSession(clientSessionToken(clientAuth)));
|
|
606
|
+
return res.json(issueUrlAuthTokenForSession(clientSessionToken(clientAuth), scope));
|
|
573
607
|
}
|
|
574
608
|
if (requireClientAuth) {
|
|
575
609
|
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
|
576
610
|
}
|
|
577
611
|
const sessionToken = await ensureSessionToken(req, res);
|
|
578
612
|
res.setHeader('Cache-Control', 'no-store');
|
|
579
|
-
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
|
613
|
+
return res.json(issueUrlAuthTokenForSession(sessionToken, scope));
|
|
580
614
|
},
|
|
581
615
|
handlePasskeyStatus: (_req, res) => {
|
|
582
616
|
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
|
|
@@ -641,8 +675,9 @@ export const createUiAuth = ({
|
|
|
641
675
|
|
|
642
676
|
const getTokenFromRequest = (req) => {
|
|
643
677
|
const cookies = parseCookies(req.headers.cookie);
|
|
644
|
-
|
|
645
|
-
|
|
678
|
+
const name = sessionCookieNameForRequest(req, cookieName);
|
|
679
|
+
if (cookies[name]) {
|
|
680
|
+
return cookies[name];
|
|
646
681
|
}
|
|
647
682
|
return null;
|
|
648
683
|
};
|
|
@@ -651,7 +686,7 @@ export const createUiAuth = ({
|
|
|
651
686
|
const secure = isSecureRequest(req);
|
|
652
687
|
const maxAgeSeconds = Math.floor(ttlMs / 1000);
|
|
653
688
|
const header = buildCookie({
|
|
654
|
-
name: cookieName,
|
|
689
|
+
name: sessionCookieNameForRequest(req, cookieName),
|
|
655
690
|
value: encodeURIComponent(token),
|
|
656
691
|
maxAge: maxAgeSeconds,
|
|
657
692
|
secure,
|
|
@@ -662,7 +697,7 @@ export const createUiAuth = ({
|
|
|
662
697
|
const clearSessionCookie = (req, res) => {
|
|
663
698
|
const secure = isSecureRequest(req);
|
|
664
699
|
const header = buildCookie({
|
|
665
|
-
name: cookieName,
|
|
700
|
+
name: sessionCookieNameForRequest(req, cookieName),
|
|
666
701
|
value: '',
|
|
667
702
|
maxAge: 0,
|
|
668
703
|
secure,
|
|
@@ -799,13 +834,17 @@ export const createUiAuth = ({
|
|
|
799
834
|
};
|
|
800
835
|
|
|
801
836
|
const handleUrlAuthToken = async (req, res) => {
|
|
837
|
+
const scope = readRequestedUrlAuthScope(req);
|
|
838
|
+
if (scope === undefined) {
|
|
839
|
+
return res.status(400).json({ error: 'Unknown URL token scope' });
|
|
840
|
+
}
|
|
802
841
|
const sessionToken = await resolveAuthenticatedSessionToken(req, { allowUrlToken: false });
|
|
803
842
|
if (!sessionToken) {
|
|
804
843
|
clearSessionCookie(req, res);
|
|
805
844
|
return respondUnauthorized(req, res);
|
|
806
845
|
}
|
|
807
846
|
res.setHeader('Cache-Control', 'no-store');
|
|
808
|
-
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
|
847
|
+
return res.json(issueUrlAuthTokenForSession(sessionToken, scope));
|
|
809
848
|
};
|
|
810
849
|
|
|
811
850
|
const handleSessionCreate = async (req, res) => {
|