@vantran-se/openchamber-web 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -24
- package/bin/cli.js +1 -3
- package/bin/cli.test.js +40 -16
- package/bin/lib/DOCUMENTATION.md +6 -5
- package/bin/lib/cli-args.js +6 -1
- package/bin/lib/cli-args.test.js +34 -0
- package/bin/lib/cli-lifecycle.js +0 -1
- package/bin/lib/cli-network.js +8 -2
- package/bin/lib/cli-process.js +1 -1
- package/bin/lib/cli-settings-accessors.test.js +21 -13
- package/bin/lib/cli-startup.js +17 -11
- package/bin/lib/cli-startup.test.js +92 -2
- package/bin/lib/commands-connect-url.js +8 -1
- package/bin/lib/commands-schedule.js +8 -2
- package/bin/lib/commands-serve.js +15 -21
- package/bin/lib/commands-update.js +12 -24
- package/bin/lib/commands-update.test.js +14 -46
- package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
- package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
- package/dist/assets/FilesView-BVoD8I2x.js +14 -0
- package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
- package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
- package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
- package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
- package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
- package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
- package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
- package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
- package/dist/assets/PlanView-RCBadcY7.js +5 -0
- package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
- package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
- package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
- package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
- package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
- package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
- package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
- package/dist/assets/de-YKfk5MmC.js +14 -0
- package/dist/assets/es-_gwT0kr-.js +20 -0
- package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
- package/dist/assets/fr-DqzvXmIm.js +18 -0
- package/dist/assets/index-BLaHKdtx.js +1 -0
- package/dist/assets/index-BkMXRrVQ.css +1 -0
- package/dist/assets/index-DxNa7xYB.js +65 -0
- package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
- package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
- package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
- package/dist/assets/ja-B-GFYhQE.js +20 -0
- package/dist/assets/ko-mzksfqDC.js +20 -0
- package/dist/assets/main-C5Xx8dn_.js +2 -0
- package/dist/assets/main-CfBSQM1H.js +990 -0
- package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
- package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
- package/dist/assets/mobile-DeEQd0wZ.js +2 -0
- package/dist/assets/nl-CWQr6NSq.js +20 -0
- package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
- package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
- package/dist/assets/pl-UmRcDUX2.js +20 -0
- package/dist/assets/pt-BR-BSLybvVF.js +20 -0
- package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
- package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
- package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
- package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
- package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
- package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
- package/dist/assets/space-apply-DM_2pRW7.js +1 -0
- package/dist/assets/space-close-Bpomt8z0.js +1 -0
- package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
- package/dist/assets/tr-gaTrX_HK.js +11 -0
- package/dist/assets/uk-CnJF6ykl.js +20 -0
- package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
- package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
- package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
- package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
- package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
- package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
- package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
- package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
- package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
- package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
- package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
- package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
- package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
- package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
- package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
- package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
- package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
- package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
- package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
- package/dist/assets/zh-CN-t2vigSB1.js +20 -0
- package/dist/assets/zh-TW-jAgqBokB.js +20 -0
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +8 -5
- package/server/index.js +80 -36
- package/server/lib/agent-memory/actions.js +1 -6
- package/server/lib/agent-memory/routes.js +1 -2
- package/server/lib/agent-memory/runtime.js +1 -8
- package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
- package/server/lib/agent-tool/runtime.js +3 -6
- package/server/lib/agent-tool/runtime.test.js +0 -2
- package/server/lib/browser-control/DOCUMENTATION.md +6 -0
- package/server/lib/changelog/update-notes.js +1 -1
- package/server/lib/context-obligatory/runtime.js +1 -2
- package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
- package/server/lib/dev-tunnel/client.js +21 -10
- package/server/lib/dev-tunnel/tunnel.test.js +34 -0
- package/server/lib/dispatch-results/runtime.js +1 -1
- package/server/lib/enterprise-mode.d.ts +1 -0
- package/server/lib/enterprise-mode.js +13 -2
- package/server/lib/enterprise-mode.test.js +21 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/index.js +1 -3
- package/server/lib/event-stream/translate-v2.js +1 -9
- package/server/lib/event-stream/translate-v2.test.js +0 -11
- package/server/lib/fs/DOCUMENTATION.md +5 -2
- package/server/lib/fs/routes.js +62 -15
- package/server/lib/fs/routes.test.js +278 -48
- package/server/lib/fs/workspace-file-names.js +104 -0
- package/server/lib/fs/workspace-file-names.test.js +62 -0
- package/server/lib/git/DOCUMENTATION.md +17 -5
- package/server/lib/git/contributor-provenance-storage.js +9 -6
- package/server/lib/git/contributor-provenance-storage.test.js +29 -0
- package/server/lib/git/credential-broker.js +1 -1
- package/server/lib/git/credential-resolver.js +1 -4
- package/server/lib/git/credentials.js +1 -0
- package/server/lib/git/discovery-endpoint.js +1 -1
- package/server/lib/git/identity-provisioning.js +2 -1
- package/server/lib/git/identity-storage.js +3 -4
- package/server/lib/git/lfs-discovery.js +14 -14
- package/server/lib/git/lfs-discovery.test.js +16 -6
- package/server/lib/git/network-operation-plan.js +50 -62
- package/server/lib/git/network-operation-plan.test.js +103 -1
- package/server/lib/git/network-operation-registry.js +4 -6
- package/server/lib/git/network-operation-storage.js +4 -6
- package/server/lib/git/network-operations.js +12 -4
- package/server/lib/git/network-operations.test.js +19 -6
- package/server/lib/git/redaction.js +2 -1
- package/server/lib/git/repository-credential-runtime.js +5 -3
- package/server/lib/git/repository-credential-runtime.test.js +22 -0
- package/server/lib/git/routes.js +31 -137
- package/server/lib/git/routes.test.js +2 -2
- package/server/lib/git/service.js +143 -27
- package/server/lib/git/service.test.js +215 -1
- package/server/lib/git/ssh-credential-storage.js +1 -4
- package/server/lib/git/submodule-discovery.js +1 -1
- package/server/lib/git/worktree-bootstrap-storage.js +3 -4
- package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
- package/server/lib/git/worktree-creation.js +194 -0
- package/server/lib/git/worktree-creation.test.js +155 -0
- package/server/lib/github/DOCUMENTATION.md +11 -6
- package/server/lib/github/auth.js +2 -4
- package/server/lib/github/device-flow.js +2 -2
- package/server/lib/github/gh-cli-credential.js +1 -0
- package/server/lib/github/octokit.js +31 -0
- package/server/lib/github/pr-status.js +35 -18
- package/server/lib/github/pr-status.test.js +271 -3
- package/server/lib/github/reference-search.js +102 -13
- package/server/lib/github/reference-search.test.js +48 -7
- package/server/lib/github/routes.auth.test.js +18 -0
- package/server/lib/github/routes.js +360 -6
- package/server/lib/github/routes.mutations.test.js +239 -0
- package/server/lib/github/routes.test.js +134 -2
- package/server/lib/gitlab/DOCUMENTATION.md +5 -1
- package/server/lib/gitlab/auth-storage.test.js +1 -0
- package/server/lib/gitlab/mappers.js +30 -1
- package/server/lib/gitlab/resources.js +230 -6
- package/server/lib/gitlab/resources.test.js +179 -0
- package/server/lib/gitlab/routes.js +200 -6
- package/server/lib/gitlab/routes.test.js +186 -2
- package/server/lib/gitlab/validation.js +1 -5
- package/server/lib/guests/DOCUMENTATION.md +1 -1
- package/server/lib/guests/oauth.js +2 -2
- package/server/lib/guests/service.js +1 -1
- package/server/lib/guests/sockets.js +1 -1
- package/server/lib/injected-env.d.ts +8 -0
- package/server/lib/injected-env.js +50 -0
- package/server/lib/injected-env.test.js +56 -0
- package/server/lib/linear/DOCUMENTATION.md +5 -6
- package/server/lib/linear/issues.js +62 -4
- package/server/lib/linear/issues.test.js +68 -0
- package/server/lib/linear/oauth.js +5 -5
- package/server/lib/linear/parse.js +2 -9
- package/server/lib/message-queue/DOCUMENTATION.md +23 -1
- package/server/lib/message-queue/runtime.js +85 -12
- package/server/lib/message-queue/runtime.test.js +172 -2
- package/server/lib/notifications/apns-runtime.js +5 -6
- package/server/lib/notifications/apns-runtime.test.js +20 -4
- package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
- package/server/lib/openchamber-control/actions.js +2 -1
- package/server/lib/openchamber-control/service.js +119 -20
- package/server/lib/openchamber-control/service.test.js +166 -0
- package/server/lib/openchamber-sessions/archive-store.js +1 -6
- package/server/lib/openchamber-sessions/routes.js +47 -12
- package/server/lib/openchamber-sessions/routes.test.js +65 -2
- package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
- package/server/lib/opencode/DOCUMENTATION.md +22 -8
- package/server/lib/opencode/agents.js +62 -0
- package/server/lib/opencode/auth-state-runtime.js +3 -1
- package/server/lib/opencode/auth.d.ts +1 -1
- package/server/lib/opencode/auth.js +8 -0
- package/server/lib/opencode/auth.test.js +31 -0
- package/server/lib/opencode/config-entity-routes.js +16 -0
- package/server/lib/opencode/config-entity-v2.test.js +16 -1
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.js +2 -4
- package/server/lib/opencode/feature-routes-runtime.js +89 -49
- package/server/lib/opencode/lifecycle.js +41 -21
- package/server/lib/opencode/lifecycle.test.js +50 -2
- package/server/lib/opencode/model-discovery.js +1 -1
- package/server/lib/opencode/model-discovery.test.js +17 -0
- package/server/lib/opencode/models-metadata.js +0 -2
- package/server/lib/opencode/openchamber-routes.test.js +3 -3
- package/server/lib/opencode/plugins.js +6 -9
- package/server/lib/opencode/provider-source-route.test.js +101 -3
- package/server/lib/opencode/providers-disabled.test.js +59 -0
- package/server/lib/opencode/providers.js +34 -0
- package/server/lib/opencode/proxy.js +11 -3
- package/server/lib/opencode/routes.js +40 -2
- package/server/lib/opencode/settings-helpers.js +27 -1
- package/server/lib/opencode/settings-helpers.test.js +19 -7
- package/server/lib/opencode/settings-registry.json +27 -16
- package/server/lib/opencode/settings-runtime.test.js +18 -0
- package/server/lib/opencode/shared-service-runtime.js +23 -104
- package/server/lib/opencode/shared.js +77 -46
- package/server/lib/opencode/shared.test.js +12 -12
- package/server/lib/opencode/shutdown-runtime.test.js +2 -3
- package/server/lib/opencode/skill-routes.js +123 -38
- package/server/lib/opencode/skill-routes.test.js +201 -0
- package/server/lib/opencode/skills.js +81 -34
- package/server/lib/opencode/skills.test.js +176 -21
- package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
- package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
- package/server/lib/opencode/watcher.test.js +0 -54
- package/server/lib/outbound-proxy.js +52 -0
- package/server/lib/outbound-proxy.test.js +55 -0
- package/server/lib/package-manager.js +23 -17
- package/server/lib/package-manager.test.js +5 -18
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
- package/server/lib/permission-auto-accept/runtime.js +19 -47
- package/server/lib/permission-auto-accept/runtime.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +7 -1
- package/server/lib/project-context/routes.js +1 -2
- package/server/lib/project-context/runtime.js +12 -14
- package/server/lib/project-context/runtime.test.js +78 -2
- package/server/lib/projects/project-config.js +30 -15
- package/server/lib/projects/project-config.test.js +30 -0
- package/server/lib/projects/project-setup.js +1 -2
- package/server/lib/projects/routes.js +1 -2
- package/server/lib/quota/DOCUMENTATION.md +35 -7
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/claude/index.js +1 -1
- package/server/lib/quota/providers/cline-pass.js +1 -1
- package/server/lib/quota/providers/exe-dev.js +1 -1
- package/server/lib/quota/providers/google/index.js +1 -1
- package/server/lib/quota/providers/hyper.js +1 -1
- package/server/lib/quota/providers/kimi.js +116 -36
- package/server/lib/quota/providers/kimi.test.js +128 -3
- package/server/lib/quota/providers/opencode-go.js +144 -21
- package/server/lib/quota/providers/opencode-go.test.js +229 -6
- package/server/lib/quota/providers/openrouter.js +1 -1
- package/server/lib/quota/providers/zenmux.js +1 -1
- package/server/lib/quota/utils/auth.js +1 -0
- package/server/lib/quota/utils/transformers.js +2 -6
- package/server/lib/relay/DOCUMENTATION.md +18 -3
- package/server/lib/relay/e2ee.js +7 -7
- package/server/lib/relay/host-client.js +84 -13
- package/server/lib/relay/host-client.test.js +201 -1
- package/server/lib/relay/identity.js +6 -29
- package/server/lib/relay/identity.test.js +18 -28
- package/server/lib/relay/key-store.js +141 -0
- package/server/lib/relay/key-store.test.js +138 -0
- package/server/lib/relay/service.js +8 -8
- package/server/lib/relay/service.test.js +10 -5
- package/server/lib/relay/signing-key.js +13 -36
- package/server/lib/relay/tunnel-codec.js +10 -10
- package/server/lib/route-module-exports.test.js +22 -0
- package/server/lib/routing/DOCUMENTATION.md +23 -3
- package/server/lib/routing/cache.js +87 -0
- package/server/lib/routing/defaults.js +11 -0
- package/server/lib/routing/history.js +12 -4
- package/server/lib/routing/runtime.js +39 -3
- package/server/lib/routing/runtime.test.js +81 -2
- package/server/lib/routing/store.js +12 -1
- package/server/lib/routing/store.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
- package/server/lib/scheduled-tasks/existing-session.js +119 -0
- package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
- package/server/lib/scheduled-tasks/loops.js +1 -8
- package/server/lib/scheduled-tasks/routes.js +8 -8
- package/server/lib/scheduled-tasks/runtime.js +103 -13
- package/server/lib/scheduled-tasks/runtime.test.js +48 -0
- package/server/lib/scheduled-tasks/service.js +33 -11
- package/server/lib/scheduled-tasks/service.test.js +128 -2
- package/server/lib/scheduled-tasks/session-defaults.js +27 -0
- package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
- package/server/lib/session-assist/context.js +1 -0
- package/server/lib/session-folders/routes.js +2 -2
- package/server/lib/session-goal/objectives.js +1 -1
- package/server/lib/session-knowledge/routes.js +2 -2
- package/server/lib/session-knowledge/runtime.js +1 -2
- package/server/lib/session-work/DOCUMENTATION.md +39 -14
- package/server/lib/session-work/questions.js +31 -2
- package/server/lib/session-work/runtime.js +22 -13
- package/server/lib/session-work/runtime.test.js +43 -26
- package/server/lib/session-work/state.js +10 -4
- package/server/lib/session-work/state.test.js +8 -1
- package/server/lib/shared/DOCUMENTATION.md +89 -0
- package/server/lib/shared/guards.js +53 -0
- package/server/lib/shared/guards.test.js +112 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
- package/server/lib/skills-catalog/cache.js +3 -2
- package/server/lib/skills-catalog/curated-sources.js +5 -1
- package/server/lib/skills-catalog/git.js +107 -20
- package/server/lib/skills-catalog/git.test.js +78 -0
- package/server/lib/skills-catalog/install.js +21 -12
- package/server/lib/skills-catalog/scan.js +25 -15
- package/server/lib/skills-catalog/source.js +26 -1
- package/server/lib/skills-catalog/source.test.js +27 -0
- package/server/lib/source-control/DOCUMENTATION.md +2 -1
- package/server/lib/source-control/audit-storage.js +7 -6
- package/server/lib/source-control/audit-storage.test.js +11 -0
- package/server/lib/source-control/binding-service.js +2 -5
- package/server/lib/source-control/change-request-worktree.js +90 -0
- package/server/lib/source-control/change-request-worktree.test.js +121 -0
- package/server/lib/source-control/mutation-executor.js +1 -1
- package/server/lib/source-control/mutation-storage.js +11 -5
- package/server/lib/source-control/provider-instance.js +1 -2
- package/server/lib/source-control/repository-identity.js +1 -1
- package/server/lib/source-control/routes.js +14 -2
- package/server/lib/spaces/DOCUMENTATION.md +42 -17
- package/server/lib/spaces/command-stream.test.js +15 -0
- package/server/lib/spaces/dispatcher.js +7 -0
- package/server/lib/spaces/dispatcher.test.js +17 -0
- package/server/lib/spaces/hardening.js +2 -2
- package/server/lib/spaces/hardening.test.js +1 -1
- package/server/lib/spaces/host.js +2 -0
- package/server/lib/spaces/journey.js +66 -5
- package/server/lib/spaces/journey.test.js +95 -4
- package/server/lib/spaces/layout.js +1 -1
- package/server/lib/spaces/places/docker-disk.js +38 -14
- package/server/lib/spaces/places/docker-disk.test.js +80 -1
- package/server/lib/spaces/places/docker.js +35 -11
- package/server/lib/spaces/places/fake-docker.js +22 -3
- package/server/lib/spaces/places/memory-place.js +3 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
- package/server/lib/spaces/routes.js +5 -0
- package/server/lib/spaces/routes.test.js +4 -2
- package/server/lib/spaces/run-command.js +9 -2
- package/server/lib/spaces/tools.js +4 -5
- package/server/lib/spaces/tools.test.js +2 -2
- package/server/lib/terminal/runtime.js +18 -6
- package/server/lib/terminal/runtime.test.js +66 -0
- package/server/lib/tts/index.js +3 -0
- package/server/lib/tts/service.js +4 -2
- package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
- package/server/lib/walkthrough/index.js +2 -3
- package/server/lib/walkthrough/pr-source.test.js +22 -0
- package/server/lib/walkthrough/sources.js +6 -1
- package/server/opencode-proxy.test.js +127 -0
- package/bin/lib/commands-startup.test.js +0 -15
- package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
- package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
- package/dist/assets/FilesView-DT0uEYgT.js +0 -17
- package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
- package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
- package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
- package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
- package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
- package/dist/assets/PlanView-DsRWnsOI.js +0 -5
- package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
- package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
- package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
- package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
- package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
- package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
- package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
- package/dist/assets/de-CBla4hjd.js +0 -14
- package/dist/assets/es-BmnZtxTD.js +0 -20
- package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
- package/dist/assets/fr-OVKIBUZO.js +0 -18
- package/dist/assets/index-CKCFKmjF.js +0 -1
- package/dist/assets/index-Dbx4V4GN.css +0 -1
- package/dist/assets/index-vQ1Pg1cr.js +0 -61
- package/dist/assets/ja-6v-yr-gE.js +0 -20
- package/dist/assets/ko-I_ZLvgQ9.js +0 -20
- package/dist/assets/main-CmO4dRtR.js +0 -2
- package/dist/assets/main-b9vqhlYM.js +0 -999
- package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
- package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
- package/dist/assets/mobile-CVtShif9.js +0 -2
- package/dist/assets/nl-DcvIJf46.js +0 -20
- package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
- package/dist/assets/pl-JxCUl0sd.js +0 -20
- package/dist/assets/pt-BR-L89mjja2.js +0 -20
- package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
- package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
- package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
- package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
- package/dist/assets/space-apply-1trGVIl8.js +0 -1
- package/dist/assets/space-close-y7j-NuBv.js +0 -1
- package/dist/assets/tr-ka5J1flt.js +0 -11
- package/dist/assets/uk-PyGe6Q_u.js +0 -20
- package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
- package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
- package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
- package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
- package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
- package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
- package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
- package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { hostOf } from './binding-service.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The change-request source for a worktree made from a pull or merge request
|
|
5
|
+
* given by number, built on the server the way the New worktree dialog builds
|
|
6
|
+
* it on the client (`prWorktreeConfig.ts`, `getBoundSourceControlReadContexts`):
|
|
7
|
+
*
|
|
8
|
+
* - The read context is the repository's: a ready bound provider first, else a
|
|
9
|
+
* remote on GitHub or a known GitLab instance read with that host's current
|
|
10
|
+
* account. GitHub wins over GitLab, as the dialog's picker does.
|
|
11
|
+
* - The project, head and head revision are the provider's current answer for
|
|
12
|
+
* that number on the context's primary remote.
|
|
13
|
+
*
|
|
14
|
+
* Nothing from the caller beyond the directory and the number reaches the
|
|
15
|
+
* result; the contributor pipeline then resolves and checks the source again.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const sourceControlError = (message, status, code) => Object.assign(new Error(message), { status, code });
|
|
19
|
+
|
|
20
|
+
// The client names the fork remote this way; the contributor pipeline renames
|
|
21
|
+
// it after the provider's own head owner anyway.
|
|
22
|
+
const requestedRemoteName = (owner) => `pr-${String(owner || '').trim().toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'head'}`;
|
|
24
|
+
|
|
25
|
+
export const createChangeRequestWorktreeSource = ({
|
|
26
|
+
readBinding,
|
|
27
|
+
listGitLabInstances,
|
|
28
|
+
readCurrentAccountId,
|
|
29
|
+
readChangeRequestHead,
|
|
30
|
+
}) => {
|
|
31
|
+
const identityForHost = (host, gitlabInstances) => {
|
|
32
|
+
if (!host) return null;
|
|
33
|
+
if (host === 'github.com') return { provider: 'github', instance: 'github.com' };
|
|
34
|
+
const instance = gitlabInstances.find((candidate) => hostOf(candidate) === host);
|
|
35
|
+
return instance ? { provider: 'gitlab', instance } : null;
|
|
36
|
+
};
|
|
37
|
+
const identityKey = (identity) => `${identity.provider}:${hostOf(identity.instance)}`;
|
|
38
|
+
|
|
39
|
+
const readContexts = async (directory) => {
|
|
40
|
+
const read = await readBinding(directory);
|
|
41
|
+
const contextFor = (identity, accountId, primaryRemote) => ({
|
|
42
|
+
directory,
|
|
43
|
+
repositoryId: read.repository.repositoryId,
|
|
44
|
+
provider: identity.provider,
|
|
45
|
+
instance: identity.instance,
|
|
46
|
+
accountId,
|
|
47
|
+
bindingRevision: read.revision,
|
|
48
|
+
primaryRemote,
|
|
49
|
+
});
|
|
50
|
+
const providers = read.binding?.providers ?? [];
|
|
51
|
+
const contexts = providers
|
|
52
|
+
.filter((provider) => provider.readiness === 'ready'
|
|
53
|
+
&& provider.endpoint?.fingerprint === read.repository.remotes.find((remote) => remote.name === provider.primaryRemote)?.fetch.fingerprint)
|
|
54
|
+
.map((provider) => contextFor(provider, provider.accountId, provider.primaryRemote));
|
|
55
|
+
// A repository nobody bound to a host, or bound to an account that is
|
|
56
|
+
// gone, is read with that host's current account, as the client does.
|
|
57
|
+
const covered = new Set(providers.filter((provider) => provider.readiness !== 'account-unavailable').map(identityKey));
|
|
58
|
+
const gitlabInstances = ['https://gitlab.com', ...await listGitLabInstances()];
|
|
59
|
+
const remotes = [...read.repository.remotes].sort((a, b) => (a.name === 'origin' ? -1 : b.name === 'origin' ? 1 : 0));
|
|
60
|
+
for (const remote of remotes) {
|
|
61
|
+
const identity = identityForHost(hostOf(remote.push.displayUrl) ?? hostOf(remote.fetch.displayUrl), gitlabInstances);
|
|
62
|
+
if (!identity || covered.has(identityKey(identity))) continue;
|
|
63
|
+
const accountId = await readCurrentAccountId(identity);
|
|
64
|
+
if (!accountId) continue;
|
|
65
|
+
covered.add(identityKey(identity));
|
|
66
|
+
contexts.push(contextFor(identity, accountId, remote.name));
|
|
67
|
+
}
|
|
68
|
+
return contexts;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return async ({ directory, number }) => {
|
|
72
|
+
const contexts = await readContexts(directory);
|
|
73
|
+
const context = contexts.find((candidate) => candidate.provider === 'github')
|
|
74
|
+
?? contexts.find((candidate) => candidate.provider === 'gitlab');
|
|
75
|
+
if (!context) {
|
|
76
|
+
throw sourceControlError('No connected GitHub or GitLab account can read this project', 409, 'AUTHENTICATION_REQUIRED');
|
|
77
|
+
}
|
|
78
|
+
const head = await readChangeRequestHead({ context, number });
|
|
79
|
+
return {
|
|
80
|
+
headBranch: head.headBranch,
|
|
81
|
+
sourceRequest: {
|
|
82
|
+
context,
|
|
83
|
+
project: head.project,
|
|
84
|
+
number,
|
|
85
|
+
expectedHeadSha: head.headSha,
|
|
86
|
+
requestedRemoteName: requestedRemoteName(head.headOwner),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createChangeRequestWorktreeSource } from './change-request-worktree.js';
|
|
3
|
+
|
|
4
|
+
const remote = (name, url) => ({
|
|
5
|
+
name,
|
|
6
|
+
fetch: { displayUrl: url, fingerprint: `fp-${name}` },
|
|
7
|
+
push: { displayUrl: url, fingerprint: `fp-${name}` },
|
|
8
|
+
});
|
|
9
|
+
const read = ({ remotes, providers = null, revision = 4 }) => ({
|
|
10
|
+
revision,
|
|
11
|
+
repository: { repositoryId: 'repo_one', remotes },
|
|
12
|
+
binding: providers ? { providers, remotes: [] } : null,
|
|
13
|
+
});
|
|
14
|
+
const head = {
|
|
15
|
+
project: { id: 'acme/app', owner: 'acme', name: 'app' },
|
|
16
|
+
headSha: 'b'.repeat(40),
|
|
17
|
+
headBranch: 'feature/fix',
|
|
18
|
+
headOwner: 'Alice',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const setup = ({ binding, accounts = {}, gitlabInstances = [] }) => {
|
|
22
|
+
const readChangeRequestHead = vi.fn(async () => head);
|
|
23
|
+
const readCurrentAccountId = vi.fn(async ({ provider, instance }) => accounts[`${provider}:${instance}`] ?? null);
|
|
24
|
+
const resolve = createChangeRequestWorktreeSource({
|
|
25
|
+
readBinding: vi.fn(async () => binding),
|
|
26
|
+
listGitLabInstances: vi.fn(async () => gitlabInstances),
|
|
27
|
+
readCurrentAccountId,
|
|
28
|
+
readChangeRequestHead,
|
|
29
|
+
});
|
|
30
|
+
return { resolve, readChangeRequestHead, readCurrentAccountId };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('change request source for a worktree made by number', () => {
|
|
34
|
+
it('reads an unbound GitHub project with the current account and its primary remote', async () => {
|
|
35
|
+
const { resolve, readChangeRequestHead } = setup({
|
|
36
|
+
binding: read({ remotes: [remote('upstream', 'https://example.com/x.git'), remote('origin', 'git@github.com:acme/app.git')], revision: 0 }),
|
|
37
|
+
accounts: { 'github:github.com': 'github.com#7' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await resolve({ directory: '/repo', number: 42 });
|
|
41
|
+
|
|
42
|
+
const context = {
|
|
43
|
+
directory: '/repo', repositoryId: 'repo_one', provider: 'github', instance: 'github.com',
|
|
44
|
+
accountId: 'github.com#7', bindingRevision: 0, primaryRemote: 'origin',
|
|
45
|
+
};
|
|
46
|
+
expect(readChangeRequestHead).toHaveBeenCalledExactlyOnceWith({ context, number: 42 });
|
|
47
|
+
expect(result).toEqual({
|
|
48
|
+
headBranch: 'feature/fix',
|
|
49
|
+
sourceRequest: {
|
|
50
|
+
context, project: head.project, number: 42,
|
|
51
|
+
expectedHeadSha: 'b'.repeat(40), requestedRemoteName: 'pr-alice',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses a ready bound provider and does not ask for that host\'s current account', async () => {
|
|
57
|
+
const { resolve, readChangeRequestHead, readCurrentAccountId } = setup({
|
|
58
|
+
binding: read({
|
|
59
|
+
remotes: [remote('origin', 'https://github.com/acme/app.git')],
|
|
60
|
+
providers: [{
|
|
61
|
+
provider: 'github', instance: 'github.com', accountId: 'github.com#9', primaryRemote: 'origin',
|
|
62
|
+
readiness: 'ready', endpoint: { fingerprint: 'fp-origin' },
|
|
63
|
+
}],
|
|
64
|
+
}),
|
|
65
|
+
accounts: { 'github:github.com': 'github.com#7' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await resolve({ directory: '/repo', number: 42 });
|
|
69
|
+
|
|
70
|
+
expect(readCurrentAccountId).not.toHaveBeenCalled();
|
|
71
|
+
expect(readChangeRequestHead.mock.calls[0][0].context).toMatchObject({ accountId: 'github.com#9', bindingRevision: 4 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('prefers GitHub over GitLab, as the dialog does', async () => {
|
|
75
|
+
const { resolve, readChangeRequestHead } = setup({
|
|
76
|
+
binding: read({ remotes: [remote('origin', 'https://gitlab.com/acme/app.git'), remote('mirror', 'https://github.com/acme/app.git')] }),
|
|
77
|
+
accounts: { 'gitlab:https://gitlab.com': 'gitlab#1', 'github:github.com': 'github.com#7' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await resolve({ directory: '/repo', number: 42 });
|
|
81
|
+
|
|
82
|
+
expect(readChangeRequestHead.mock.calls[0][0].context).toMatchObject({ provider: 'github', primaryRemote: 'mirror' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reads a merge request on a connected self-hosted GitLab', async () => {
|
|
86
|
+
const { resolve, readChangeRequestHead } = setup({
|
|
87
|
+
binding: read({ remotes: [remote('origin', 'git@gitlab.example.com:team/app.git')] }),
|
|
88
|
+
accounts: { 'gitlab:https://gitlab.example.com': 'https://gitlab.example.com#3' },
|
|
89
|
+
gitlabInstances: ['https://gitlab.example.com'],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await resolve({ directory: '/repo', number: 7 });
|
|
93
|
+
|
|
94
|
+
expect(readChangeRequestHead.mock.calls[0][0]).toMatchObject({
|
|
95
|
+
number: 7,
|
|
96
|
+
context: { provider: 'gitlab', instance: 'https://gitlab.example.com', accountId: 'https://gitlab.example.com#3' },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('refuses a project no connected account can read, before asking any provider', async () => {
|
|
101
|
+
const { resolve, readChangeRequestHead } = setup({
|
|
102
|
+
binding: read({ remotes: [remote('origin', 'https://github.com/acme/app.git')] }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await expect(resolve({ directory: '/repo', number: 42 })).rejects.toMatchObject({
|
|
106
|
+
status: 409, code: 'AUTHENTICATION_REQUIRED', message: 'No connected GitHub or GitLab account can read this project',
|
|
107
|
+
});
|
|
108
|
+
expect(readChangeRequestHead).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('passes a provider refusal through unchanged', async () => {
|
|
112
|
+
const gone = Object.assign(new Error('Pull request #42 has no branch to check out: its fork was deleted'), { status: 409 });
|
|
113
|
+
const { resolve, readChangeRequestHead } = setup({
|
|
114
|
+
binding: read({ remotes: [remote('origin', 'https://github.com/acme/app.git')] }),
|
|
115
|
+
accounts: { 'github:github.com': 'github.com#7' },
|
|
116
|
+
});
|
|
117
|
+
readChangeRequestHead.mockRejectedValueOnce(gone);
|
|
118
|
+
|
|
119
|
+
await expect(resolve({ directory: '/repo', number: 42 })).rejects.toBe(gone);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -129,7 +129,7 @@ export function createMutationExecutor({ store, auditStore, runtimeIdentity }) {
|
|
|
129
129
|
}
|
|
130
130
|
const existing = await auditStore.read(auditId(record));
|
|
131
131
|
const target = {
|
|
132
|
-
kind: 'change-request',
|
|
132
|
+
kind: record.kind.startsWith('issue-') ? 'issue' : 'change-request',
|
|
133
133
|
operation: record.kind,
|
|
134
134
|
projectId: record.target.project.id,
|
|
135
135
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject, isString } from '../shared/guards.js';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -11,17 +12,21 @@ const KINDS = [
|
|
|
11
12
|
'change-request-update',
|
|
12
13
|
'change-request-merge',
|
|
13
14
|
'change-request-ready',
|
|
15
|
+
'change-request-comment',
|
|
16
|
+
'change-request-review',
|
|
17
|
+
'issue-comment',
|
|
18
|
+
'change-request-state',
|
|
19
|
+
'issue-state',
|
|
20
|
+
'change-request-labels',
|
|
21
|
+
'issue-labels',
|
|
22
|
+
'change-request-reviewers',
|
|
14
23
|
];
|
|
15
24
|
const STATES = ['running', 'succeeded', 'failed', 'outcome-unknown'];
|
|
16
25
|
const TERMINAL_STATES = ['succeeded', 'failed'];
|
|
17
26
|
const TARGET_OPTIONAL_KEYS = ['number', 'head', 'base', 'headSha'];
|
|
18
|
-
const RESULT_KEYS = [...TARGET_OPTIONAL_KEYS, 'state', 'merged', 'ready', 'failureStatus', 'failureCode'];
|
|
27
|
+
const RESULT_KEYS = [...TARGET_OPTIONAL_KEYS, 'state', 'merged', 'ready', 'commented', 'failureStatus', 'failureCode'];
|
|
19
28
|
|
|
20
29
|
const emptyState = () => ({ version: VERSION, records: {} });
|
|
21
|
-
const isPlainObject = (value) => value === Object(value)
|
|
22
|
-
&& !Array.isArray(value)
|
|
23
|
-
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
24
|
-
const isString = (value) => Object.prototype.toString.call(value) === '[object String]';
|
|
25
30
|
const isBoolean = (value) => Object.prototype.toString.call(value) === '[object Boolean]';
|
|
26
31
|
const isFunction = (value) => Object.prototype.toString.call(value) === '[object Function]';
|
|
27
32
|
const isNonEmptyString = (value) => isString(value) && value.length > 0;
|
|
@@ -83,6 +88,7 @@ const isTerminalResult = (value) => isPlainObject(value)
|
|
|
83
88
|
&& (value.headSha === undefined || isNonEmptyString(value.headSha))
|
|
84
89
|
&& (value.state === undefined || isNonEmptyString(value.state))
|
|
85
90
|
&& (value.merged === undefined || isBoolean(value.merged))
|
|
91
|
+
&& (value.commented === undefined || isBoolean(value.commented))
|
|
86
92
|
&& (value.ready === undefined || isBoolean(value.ready))
|
|
87
93
|
&& (value.failureStatus === undefined || isFailureStatus(value.failureStatus))
|
|
88
94
|
&& (value.failureCode === undefined || isNonEmptyString(value.failureCode));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import { isString } from '../shared/guards.js';
|
|
1
2
|
import { normalizeGitLabInstance } from '../gitlab/instance.js';
|
|
2
3
|
|
|
3
|
-
const isString = (value) => Object.prototype.toString.call(value) === '[object String]';
|
|
4
|
-
|
|
5
4
|
export function normalizeSourceControlProviderInstance(provider, instance) {
|
|
6
5
|
const value = isString(instance) ? instance.trim() : '';
|
|
7
6
|
if (!value) throw new Error('Source control provider instance is required');
|
|
@@ -66,7 +66,7 @@ const resolveIdentity = async (directory, {
|
|
|
66
66
|
return identity;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
function createRepositoryIdentityResolver({
|
|
70
70
|
fsImpl = fs,
|
|
71
71
|
resolveGitPaths = resolveRepositoryGitPaths,
|
|
72
72
|
getRepositoryRemotes = getRepositoryRemoteUrls,
|
|
@@ -4,8 +4,9 @@ import { registerGitHubRoutes } from '../github/routes.js';
|
|
|
4
4
|
import { registerGitLabRoutes } from '../gitlab/routes.js';
|
|
5
5
|
import { createBindingService } from './binding-service.js';
|
|
6
6
|
import { createBindingStore } from './binding-storage.js';
|
|
7
|
+
import { createChangeRequestWorktreeSource } from './change-request-worktree.js';
|
|
7
8
|
import { createGitIdentityProvisioning } from '../git/identity-provisioning.js';
|
|
8
|
-
import
|
|
9
|
+
import { createProfile, getProfiles, updateProfile } from '../git/identity-storage.js';
|
|
9
10
|
import { resolveRepositoryIdentity } from './repository-identity.js';
|
|
10
11
|
import { redactSensitiveText } from './url-redaction.js';
|
|
11
12
|
import { createOAuthFlowRegistry } from './oauth-flow-registry.js';
|
|
@@ -73,7 +74,7 @@ export function registerSourceControlRoutes(app, dependencies = {}) {
|
|
|
73
74
|
// Connecting an account is the moment every part of an identity is known, so
|
|
74
75
|
// the identity is made there instead of being asked for again later.
|
|
75
76
|
const identityProvisioning = dependencies.identityProvisioning ?? createGitIdentityProvisioning({
|
|
76
|
-
store:
|
|
77
|
+
store: { createProfile, getProfiles, updateProfile },
|
|
77
78
|
});
|
|
78
79
|
const listGitHubAccounts = dependencies.listGitHubAccounts ?? getGitHubAuthAccounts;
|
|
79
80
|
const oauthFlowRegistry = dependencies.oauthFlowRegistry ?? createOAuthFlowRegistry();
|
|
@@ -291,5 +292,16 @@ export function registerSourceControlRoutes(app, dependencies = {}) {
|
|
|
291
292
|
code: 'INVALID_SOURCE_CONTROL_READ_CONTEXT', status: 400,
|
|
292
293
|
});
|
|
293
294
|
},
|
|
295
|
+
// A worktree from a pull or merge request given by number (session.create).
|
|
296
|
+
resolveChangeRequestWorktreeSource: createChangeRequestWorktreeSource({
|
|
297
|
+
readBinding: bindingService.get,
|
|
298
|
+
listGitLabInstances: gitlab.listInstances,
|
|
299
|
+
readCurrentAccountId: ({ provider, instance }) => (provider === 'github'
|
|
300
|
+
? github.readCurrentAccountId()
|
|
301
|
+
: gitlab.readCurrentAccountId(instance)),
|
|
302
|
+
readChangeRequestHead: (input) => (input.context.provider === 'github'
|
|
303
|
+
? github.readChangeRequestHead(input)
|
|
304
|
+
: gitlab.readChangeRequestHead(input)),
|
|
305
|
+
}),
|
|
294
306
|
});
|
|
295
307
|
}
|
|
@@ -64,7 +64,7 @@ Stage 3a added three more and changed none. The argv from `execArgv` runs a comm
|
|
|
64
64
|
|
|
65
65
|
A new place is done when `runPlaceContractSuite` and `runEscapeSuite` pass against it.
|
|
66
66
|
|
|
67
|
-
Since 5e-4 the Docker place also has `readDisk()` and `cleanUpDisk()`, for the places page in Settings. They are not in the contract and not in the registry's required list: what a place keeps on disk, and what it can make again, is its own. The journey calls them on its one place; a later place brings its own or the page has nothing to say for it.
|
|
67
|
+
Since 5e-4 the Docker place also has `readDisk()` and `cleanUpDisk()`, for the places page in Settings, and since the image button of 2026-10-09 `pullImage()`, which starts or joins the one download of the image, and `imagePulling()`, whether one runs. They are not in the contract and not in the registry's required list: what a place keeps on disk, and what it can make again, is its own. The journey calls them on its one place; a later place brings its own or the page has nothing to say for it. The memory place answers them with nothing to download.
|
|
68
68
|
|
|
69
69
|
## Labels
|
|
70
70
|
|
|
@@ -148,7 +148,7 @@ A space downloads nothing to start. Its programs come from a tools volume: one v
|
|
|
148
148
|
|
|
149
149
|
The volume is a plain npm project at `/opt/openchamber-tools`, with `package.json`, `node_modules`, and the binaries in `node_modules/.bin`. It is not a global install. A development build needs the local `@openchamber/sdk` tarball to win over the published package with the same version number. npm `overrides` in a project `package.json` does that. A `-g` install would pull the published sdk as a dependency of `web`.
|
|
150
150
|
|
|
151
|
-
Contents: `@openchamber
|
|
151
|
+
Contents: `@vantran-se/openchamber-web`, `@opencode/cli`, and `@opencode/plugin` at the version of `@opencode/cli`.
|
|
152
152
|
|
|
153
153
|
### Sources and the key
|
|
154
154
|
|
|
@@ -230,7 +230,7 @@ Environment:
|
|
|
230
230
|
| `PATH` with `/opt/openchamber-tools/node_modules/.bin` last | The server refuses to start without `opencode` on PATH. The image has no `openchamber` and no `opencode`, so both are found there. The image's own directories come first, so a transitive npm package that ships a bin named `node` or `sh` never shadows the image's program. |
|
|
231
231
|
| `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`, all four `http://gatekeeper:3128` | The corridor. curl 7.88.1, the version in this image, ignores an uppercase `HTTP_PROXY` on purpose, the httpoxy protection, and honours the lowercase one. Without the lowercase spelling, plain-HTTP traffic from curl and from everything that uses libcurl never reaches the corridor. |
|
|
232
232
|
| `NO_PROXY`, `no_proxy`, both `gatekeeper,localhost,127.0.0.1` | Window traffic skips the corridor, and so does the space's own loopback. Measured: with `http_proxy` set, curl sends even a request to 127.0.0.1 to the corridor, and Node with `NODE_USE_ENV_PROXY=1` proxies loopback too. curl reads both spellings. |
|
|
233
|
-
| `NODE_USE_ENV_PROXY=1` | Node
|
|
233
|
+
| `NODE_USE_ENV_PROXY=1` | Node ignores proxy variables without it, on 22 and still on 24. Measured: `EAI_AGAIN` for every fetch, and with it Node honours the uppercase spelling. |
|
|
234
234
|
| `OPENCODE_DISABLE_MODELS_FETCH=1`, `OPENCODE_DISABLE_AUTOUPDATE=1` | The catalog and the update check would spend corridor attempts on downloads a space does not need. Measured with OpenCode 2.0.15: without `OPENCODE_DISABLE_MODELS_FETCH` the journal shows `models.opencode.ai:443 deny:not-on-allowlist`, and with it the journal stays empty. The 2.0.15 binary does read `OPENCODE_DISABLE_AUTOUPDATE`, `1` or `true`, and then logs that it skipped the update check. What it changes in a space was not seen: `opencode serve` made no update attempt with it or without it. It stays because the binary honours it and it costs nothing. |
|
|
235
235
|
| `OPENCHAMBER_RELAY_HOST=off` | The server inside a space must never host the relay passively. It has a way out now, and paired devices must not land on a space. `server/index.js` reads this variable when it computes `allowPassiveHost`. |
|
|
236
236
|
| `OPENCHAMBER_SPACE_IDLE_STOP_FILE=/home/space/.openchamber-space/idle-stop.json` | Since 5d-3: where the server inside reads the idle stop's setting. Only a space has it, so the idle timer runs nowhere else. See "Idle stop" under "The journey". |
|
|
@@ -528,8 +528,9 @@ All under `/api/openchamber/spaces`, a namespace of the host's beside `/api/open
|
|
|
528
528
|
| `POST /<id>/restart-opencode` | Since 5d-2: asks the server inside a running space to restart its OpenCode, and answers the listed entry once it is ready again. See "Repair". |
|
|
529
529
|
| `POST /<id>/restart` | Since 5d-2: restarts the container of a running space with a fresh token, then says the network and the grants again as a start does. Answers as start does. See "Repair". |
|
|
530
530
|
| `DELETE /<id>` | Saves the space's chats to the archive, then removes the containers, networks and volumes, the service refs in the user's repository and the record; forgets a failed creation. `{ id, removed, refsRemoved, failures, chats }`. 409 `chats_not_saved` when the chats cannot all be saved, and `?unsavedChats=delete` deletes anyway. See "Chat archive". |
|
|
531
|
-
| `GET /places/<place>/disk` | Since 5e-4: `{ imageBytes, toolsBytes, spacesBytes, freeBytes, freesImage }`, the disk the place's spaces take and what a clean-up would free now. See "Places page". |
|
|
532
|
-
| `POST /places/<place>/clean-up` | Since 5e-4: removes what OpenChamber can make again; `{ freedBytes, kept: [{ kind, reason }], disk }`. 409 `space_preparing` while a space is being made. See "Places page". |
|
|
531
|
+
| `GET /places/<place>/disk` | Since 5e-4: `{ imageBytes, imagePulling, imageFailure, toolsBytes, spacesBytes, freeBytes, freesImage }`, the disk the place's spaces take, whether the image is being downloaded and why the last download failed, and what a clean-up would free now. See "Places page". |
|
|
532
|
+
| `POST /places/<place>/clean-up` | Since 5e-4: removes what OpenChamber can make again; `{ freedBytes, kept: [{ kind, reason }], disk }`. 409 `space_preparing` while a space is being made, 409 `image_pulling` while the image is being downloaded. See "Places page". |
|
|
533
|
+
| `POST /places/<place>/image` | Downloads the image ahead of the first space (journey step 0); answers 202 with the disk, `imagePulling` set, while the download goes on. See "Places page". |
|
|
533
534
|
| `GET /archives` | Since 5e-2: `{ archives: [{ spaceId, name, projectDirectory, directory, archivedAt }] }`, which directory holds which deleted space's chats. Reachable with the switch off too, see "Chat archive". |
|
|
534
535
|
| `POST /<id>/setup` | Since 5d-4: `{ commands }`. Runs the project's setup commands again in a running space; answers the listed entry once the run began. See "Setup commands". |
|
|
535
536
|
| `GET /<id>/setup` | Since 5d-4: `{ setup, output }`, the setup as the list carries it and the end of the failed command's output, or null. |
|
|
@@ -589,11 +590,13 @@ DESIGN.md, journey step 9 and decision 12; built in 5e-3, UI only on top of `GET
|
|
|
589
590
|
|
|
590
591
|
DESIGN.md, journey step 9 and decision 12; built in 5e-4, `places/docker-disk.js` behind the two routes above. The maintainer's calls of 2026-09-30: Settings has a page of its own, "Isolated spaces" in the Workspace group, which holds the switch, the idle stop, the places and the spaces without a project, and the phone lists it among its Settings pages; a place shows its Docker version, how many spaces run and stand stopped, the disk, the spaces whose container is gone with their "Delete", and "Clean up…" with one confirmation that says what it frees and, when the image goes, that it downloads again; a place that cannot be reached says what to do and lists the spaces this window last saw, never a count of zero, and nothing polls.
|
|
591
592
|
|
|
592
|
-
- **What it measures.** `docker system df --verbose` for the volumes, read by name for this owner's tools volumes and space volumes, which are found by label and checked again on the inspect result; `docker image inspect` for the pinned image. Docker prints volume sizes rounded to three digits in decimal units, so the page's numbers are Docker's, as `docker system df` shows them. `freeBytes` counts the old tools volumes no container mounts
|
|
593
|
-
- **What a clean-up removes.** This owner's tools volumes other than the current one, this owner's tools one-shot containers,
|
|
593
|
+
- **What it measures.** `docker system df --verbose` for the volumes, read by name for this owner's tools volumes and space volumes, which are found by label and checked again on the inspect result; `docker image inspect` for the pinned image. Docker prints volume sizes rounded to three digits in decimal units, so the page's numbers are Docker's, as `docker system df` shows them. `freeBytes` counts the old tools volumes no container mounts, the image when no container at all, anyone's, was made from it, and each retired image the clean-up would remove that no container was made from. A retired image has no number of its own on the page, by the maintainer's call of 2026-10-08. Each image counts what it alone takes, so layers the current and a retired image share are counted for neither while both are there: a clean-up that removes both frees more than the page said.
|
|
594
|
+
- **What a clean-up removes.** This owner's tools volumes other than the current one, this owner's tools one-shot containers, the space image by its pinned reference, and the retired images below. Each goes without force, one by one. Docker refuses a volume a container mounts, a stopped one included, an image any container was made from, and a running container; that refusal is the guard, and whatever it keeps is reported as `in_use`. Nothing here keeps its own account of what is in use. The current tools volume, anything without this owner's labels, the spaces' own volumes and a space whose container is gone are never asked to go: a space's volumes may hold work that was never applied, so they go only with that space's own "Delete".
|
|
594
595
|
- **The image is the exception to the labels.** It is a public image, pulled and never labelled, and the only resource a clean-up removes without our labels. It is named by the pinned digest and removed without force, so any container made from it keeps it. Measured on Docker 29.2.1 with the containerd image store: removing an image by its digest reference removes it whole, a tag the user gave the same image included. A user who pulled the same `node` image for their own use pulls it again.
|
|
596
|
+
- **Retired images.** `RETIRED_SPACE_BASE_IMAGES` in `places/docker.js` lists the digests the pinned image replaced, so a bump does not leave the old image on the disk for good. A clean-up treats each like the current image, with one more guard, by the maintainer's call of 2026-10-08: it skips a retired image whose `RepoTags` hold anything besides its own digest reference. A space pulls by digest, so another tag means someone pulled or tagged the same image for their own use, and the removal by digest would take that tag with it. Measured on Docker 29.5.2 with the containerd image store: an image pulled by digest has only its digest reference in `RepoTags`, and the same image pulled by tag has the tag as well. Not measured on the classic image store, where an image pulled by digest is expected to have no `RepoTags` at all, which the guard also lets through. A retired image any container was made from stays, reported as `in_use`. That includes the gatekeeper of every space created before the bump: a start moves a space container to the current image along with its tools, but leaves the gatekeeper on the image it was made from, by the maintainer's call of 2026-10-08. The retired image therefore goes at the first clean-up after the last such space is removed.
|
|
595
597
|
- **Colima.** Space freed inside the Colima machine does not return to the Mac until the machine trims its disk. After a clean-up that removed anything, when `docker info` names the machine `colima` or `colima-<profile>`, the place runs `colima ssh --profile <profile> -- sudo -n fstrim --all`, found with the host's augmented PATH beside `docker`, within two minutes. Any failure, a missing CLI, a stopped machine or a `sudo` that wants a password among them, is logged and never fails the clean-up. Measured: about a second on a 40 GiB machine. Docker Desktop, Linux and Windows run nothing.
|
|
596
|
-
- A clean-up is refused while a space is being made, because the creation pulls the image and fills the tools before any container holds them. A start of a stopped space needs no such refusal: its containers hold the image and its tools.
|
|
598
|
+
- A clean-up is refused while a space is being made, because the creation pulls the image and fills the tools before any container holds them, and while the image is being downloaded. A start of a stopped space needs no such refusal: its containers hold the image and its tools.
|
|
599
|
+
- **The image ahead of the first space.** DESIGN.md, journey step 0; the maintainer's call of 2026-10-09. While the image is absent the place's block shows "Download image" beside "Clean up…"; pressed, it asks `POST /places/<place>/image`, which starts the pull and answers the disk at once. The place runs one `docker pull` at a time: a creation that needs the image while one runs waits on the same pull, and a second press is the same pull. The disk read carries `imagePulling`, and `imageFailure`, the code and message of the last download that failed, while the image is absent and no download runs; the page reads the disk again every three seconds while `imagePulling` is set, the one thing on this page that polls, and shows the failure under the buttons. `docker pull` prints no byte counts without a terminal, so the button says "Downloading…" with a spinner and no percentage; when the pull ends the disk line shows the image's size and the button goes. A pull that fails is logged by its code and not thrown to the page: the next disk read says it.
|
|
597
600
|
|
|
598
601
|
### Idle stop
|
|
599
602
|
|
|
@@ -604,7 +607,7 @@ DESIGN.md, decision 11 and the last line of "Dispatcher, sessions, events"; buil
|
|
|
604
607
|
- **What the host sees.** The place's list carries `stoppedIdle`, an exited space whose exit code is 75, and `gatekeeperRunning`. A server stopped by `docker stop` exits 143. The journey's list passes `stoppedIdle` on, and the group says the space stopped after sitting idle, with Start.
|
|
605
608
|
- **The gatekeeper.** Nothing inside can stop it, so an idle stop leaves it running. The journey's list stops a gatekeeper it finds running beside an exited space, and leaves it when another action holds the space; a start that comes meanwhile waits for that stop instead of being refused as busy. Turning the switch off stops it too. A key typed into it goes with it, as after a stop by hand. The maintainer's call of 2026-09-28: a self-stopping gatekeeper is more machinery than 24 MiB for a while is worth.
|
|
606
609
|
- **Not a boundary.** The agent can change the file, keep a session busy, or exit with 75 itself. That keeps its space running or changes a line of text; it restricts nothing.
|
|
607
|
-
- In the UI, a space stream that drops while the list says the space runs makes the client read the list again, at the host's reconnect pace, so an idle stop shows as stopped and not as "not answering".
|
|
610
|
+
- In the UI, a space stream that drops while the list says the space runs makes the client read the list again, at the host's reconnect pace, so an idle stop shows as stopped and not as "not answering". While the list says a space is stopped the client sends it nothing: the sync watchdog and the browser panel's dev-server look skip its directories, and a message sent to it stays in the composer with "Start", instead of the dispatcher's 503 as a toast. A stream that comes back on such a space makes the client read the list again, so a start from another window reaches every window.
|
|
608
611
|
|
|
609
612
|
### Setup commands
|
|
610
613
|
|
|
@@ -625,13 +628,13 @@ DESIGN.md, "Words" and "Gatekeeper", and decisions 4, 5 and 13. Stage 5b takes t
|
|
|
625
628
|
- An opened domain gets a random id, `open-<12 hex>`, and its window URL, `http://gatekeeper:8080/model/<id>`, which the answer carries so a screen can show it. Nothing inside is configured for it in this stage.
|
|
626
629
|
- The key goes to the gatekeeper first, then the grant without its value goes to the record, and for a model grant OpenCode inside is told: `space-opencode.js` writes the whole global configuration, `$HOME/.config/opencode/opencode.json`, from the record's model grants, each provider with `options.baseURL` at its window URL and a placeholder `apiKey`, so OpenCode's own auth plugins stay out of the way and the window throws the placeholder away. That file cooperates and enforces nothing: the agent can change it, and the gatekeeper is what keeps the key out. The catalog a space offers is the host's, so the model chosen in the composer works inside unchanged; that was the second decision of 2026-09-26. A grant the gatekeeper refused is not remembered. A grant goes to a running space only, `space_not_running` otherwise, and to one whose record can be read, `space_record_unreadable` otherwise, because a grant that cannot be remembered would be gone at the next start without a word. The configuration is the whole file, so anything the agent wrote into its global `opencode.json` is gone at the next model grant or start; a project's own `opencode.json` is not touched.
|
|
627
630
|
- The providers a model grant takes are the ones whose header this module knows: `anthropic` and `google` with their own headers, and `openai`, `openrouter`, `groq`, `mistral`, `deepseek` and `xai` with a bearer token. Any other is refused with `provider_not_supported` rather than accepted and failing every turn with a 401 upstream; Azure, Bedrock and Vertex read their key another way or sign requests. The grant dialog decides how the list grows.
|
|
628
|
-
- After every start the journey says the record's grants again, after the network: an environment key is read again and delivered, an opened domain is delivered, and a typed key cannot be, because it was never kept. The provider configuration inside is written again from the record too, so a write that failed at the grant is repaired by the next start; a write that fails at the start is logged and the start goes on. A grant the host cannot read in the record, from a later version, is dropped with a warning and the rest of the record stays readable. The start answers `grantsRestored` and `needsAccess` with the ids. A gatekeeper that refuses one at the start leaves the space running and that grant in `needsAccess`, logged; the alternative, a start that fails with the space up and the network said, would be worse.
|
|
631
|
+
- After every start the journey says the record's grants again, after the network: an environment key is read again and delivered, an opened domain is delivered, and a typed key cannot be, because it was never kept. The provider configuration inside is written again from the record too, so a write that failed at the grant is repaired by the next start; a write that fails at the start is logged and the start goes on. A grant the host cannot read in the record, from a later version, is dropped with a warning and the rest of the record stays readable. The start answers `grantsRestored` and `needsAccess` with the ids. A gatekeeper that refuses one at the start leaves the space running and that grant in `needsAccess`, logged; the alternative, a start that fails with the space up and the network said, would be worse. When the host starts, `restoreLostGrants` asks the gatekeeper of every running space what it holds and says the record's environment keys and opened domains it lacks, for a start a previous host did not live to finish; a typed key still waits for the user, a space with an action under way and a gatekeeper that does not answer are left alone, logged.
|
|
629
632
|
- The list, with `access`, asks each running space that has grants what its gatekeeper holds, `GET /policy`, one request per such space and none for a space without grants or one that is stopped, and says `access: 'granted'`, `'needs_access'` with the missing ids in `needsAccess`, or `'unknown'` when the gatekeeper could not be asked, never "granted" on a failed read. While an action holds the space, a start saying its grants again among them, `access` is null rather than a "needs access" that is about to be untrue. `GET /` asks with `access`; the other routes answer the list without it, `access: null`. This is the "needs access" of DESIGN.md after a machine restart, read from the gatekeeper rather than from what this process remembers, so a host that restarted shows the truth.
|
|
630
633
|
|
|
631
634
|
- An opened domain of the grant dialog is a name added to the allowlist, the corridor's, and not the window grant above: since 5d `POST /<id>/network/domains` tells the gatekeeper the list with the name first and the record after, so a record that cannot be written leaves the name open only until the next start, which closes it. It is the same action as "Open" on a blocked attempt of the journal, whose name the agent wrote: the user reading it and pressing the button is the decision, and the allowlist's name rule, applied here as in the corridor, refuses anything that is not a name. A space whose network is open refuses it with `network_is_open`, and the list holds at most 200 names, `too_many_domains` past that. A window grant for a domain has no screen yet, because nothing inside a space uses it before npm and git go through the window.
|
|
632
635
|
|
|
633
636
|
- The list route runs one `docker ps` and one record read per space on every call; there is no cache, because the user is looking and the answer must be the state now.
|
|
634
|
-
- A creation is one process's memory: a host that restarts in the middle of one leaves the space where the step got to, listed from the labels with whatever the record holds
|
|
637
|
+
- A creation is one process's memory: a host that restarts in the middle of one leaves the space where the step got to, listed from the labels with whatever the record holds. A space whose code never arrived has an empty `spacePath` in its record, and the list says so since the stage 5 leftovers: `state: 'failed'` with the failure `space_code_never_arrived`, as for a creation that failed in this process, so the group offers Remove and nothing else. Before that it was listed as running, and the user could send the agent into an empty folder; the packaged desktop quit check of stage 3a found it. The containers are not removed on the host's behalf: Remove is the user's action, as for every failed creation. A host that quit between making the containers and writing the record leaves a space with no record at all, which is still listed as running with `network: null`; that window is a few hundred milliseconds and is not closed here.
|
|
635
638
|
- The history transfer is not retried on its own; a start of the space retries it while the record says `pending` or `failed`.
|
|
636
639
|
- The lock on a space is one process's memory, like code out's turns: two OpenChamber processes on one Docker daemon are not coordinated.
|
|
637
640
|
- The chat archive keeps what the export holds: a turn still running when the space is deleted is not in it, and a chat created in the space after its list was read goes with the space.
|
|
@@ -642,6 +645,28 @@ DESIGN.md, "Words" and "Gatekeeper", and decisions 4, 5 and 13. Stage 5b takes t
|
|
|
642
645
|
- Whether OpenCode inside takes a provider granted after the instance of the space's project started, without a restart, is what the live file of 5b measures; see the pull request of 5b for the outcome. The restart action of the repair stage is the lever if it does not.
|
|
643
646
|
- A grant for a provider the host's catalog does not know is written inside all the same; OpenCode inside then has no `npm` package for it and the turn fails with a provider error. The grant dialog offers the host's catalog, so this is a request nothing in the app makes.
|
|
644
647
|
|
|
648
|
+
### Preview
|
|
649
|
+
|
|
650
|
+
DESIGN.md, journey step 5 and decision 14; built in stage 6. The maintainer's calls of 2026-10-08: a space's dev servers are opened from the same place as the host's, the list on the empty "Browser" tab beside the chat, which for a chat of a space shows the servers listening inside that space; no port is granted, because the traffic goes into the space; the page opens on the desktop only, and in a browser tab or on the phone the list shows with its rows greyed and a computer icon that says the desktop app is needed; the agent inside a space cannot open or drive the panel; and a page from a space reaches nothing but the space, the internet included.
|
|
651
|
+
|
|
652
|
+
Nothing of it is new on the server: `/api/spaces/<id>/dev-servers` is forwarded like any route, and the server inside answers with the ports listening inside the container, read from `/proc/net/tcp`, its own port and OpenCode's left out; `/api/spaces/<id>/dev-tunnel` is one of the four forwarded sockets since 4b, and the server inside dials the port on its own loopback after checking it against its own discovery, so the tunnel reaches a port the user could see and no other. The UI addresses both by the directory of the tab, which the prefix rule turns into the space's prefix, so a space's tab lists and opens the space's ports whether the instance is local or remote.
|
|
653
|
+
|
|
654
|
+
The desktop opens the page through its dev-server tunnel, the one it already uses for a dev server of a remote instance, so the page loads from a local port of its own, `openchamber-preview.localhost:<port>`, under an origin that is not the app's. Three things are the space's alone:
|
|
655
|
+
|
|
656
|
+
- **Its tunnel.** The desktop's tunnel client takes the space id and dials the space's prefix; a space's tunnel is keyed apart from the host's on the same remote port, so the two never share a local port. Over the private relay the same id travels with each connection, and the renderer dials the prefixed socket.
|
|
657
|
+
- **Its session.** The page's view runs in a partition of the space's own, `openchamber-space-preview:<id>`, in memory, so it holds no cookie of the app's or of any page the user previewed, and the shell's hardening of the panel session, user agent, permissions and device pickers, applies to it. The shell refuses a view attached under any other partition than the panel's or a space's, and takes the preload and node integration off every view it attaches.
|
|
658
|
+
- **Its reach.** The local API of the desktop runs without a password, and a page shown on this machine could call it, as it could the user's own dev servers and anything on the local network, which no gatekeeper stands in front of. So the space's session is routed through a proxy that answers nothing, a listener of the shell's that drops every connection, and bypasses it only for that space's tunnel ports; Chromium's implicit loopback bypass is taken away with `<-loopback>`. The rules are `space-preview.mjs` in `packages/electron`, applied before a tunnel's port is handed to the renderer and again, with no port, the moment a view with a space partition is attached. The page reaches the space and nothing else: a font from a CDN does not load in it, and neither does a request to the host's API, to a port on this machine, or to a name that resolves to one.
|
|
659
|
+
|
|
660
|
+
The panel's views are keyed by their directory as well as their tab since this stage: before, a tab of the next directory with the same id kept the previous directory's view, and a space's page loaded in the host's session, which the live run showed before the fix. A PAC script was tried first for the rules and dropped: Chromium keeps its implicit bypass for loopback and `*.localhost` under a PAC script, and a page from the space reached a port on this machine; only `<-loopback>` in manual bypass rules takes that bypass away.
|
|
661
|
+
|
|
662
|
+
Three more rules came out of the review of the stage. The icon a page reports is fetched by the shell only for a view in the user's own panel session; a space's view has a session of its own, so its page, whose icon URL the agent chose, gets no tab icon at all. Measured before that rule: the shell fetched the icon from the local service the page named, in the space's session too, since `net.fetch` does not go through the session's proxy. A space's view is attached only after the renderer asked the shell to put the proxy on the space's session, so a saved public address does not load direct in the moment before the attach-time apply. And every closed tunnel of a space, a window closing among the reasons, takes its port out of the space's session, so the list never holds a port another listener could later take.
|
|
663
|
+
|
|
664
|
+
What the user sees of all this: in the list, a space's server carries the container icon, with the space's name and place behind it on hover, "preview · On this computer (Docker)"; a page from a space shows the same icon before its address; in a browser tab and on the phone the row is greyed with a computer icon, "Opens in the desktop app" on hover, and no sentence under the list. The maintainer's call of 2026-10-08: show, do not write.
|
|
665
|
+
|
|
666
|
+
The "Open in browser" action of the toolbar is off for a space's tab: the system browser has no such proxy. The host's status probe, `/api/system/probe-url`, is host-only and is not asked for a space's page; a page that loaded counts as served. A loopback address a space's page navigates to is tunnelled into the same space, as a remote instance's pages keep their other ports on the host.
|
|
667
|
+
|
|
668
|
+
Known limits: the proof that Chromium reads the proxy rules as written is the live run, since the rules are text; a port the user hides from the list is hidden by number for the host and every space alike; and the tunnel's local port accepts any local connection, so a page opened by hand in another browser at that address has that browser's reach and not the panel's, which is why the panel does not offer it.
|
|
669
|
+
|
|
645
670
|
## Code in
|
|
646
671
|
|
|
647
672
|
The project's code travels into a space as git objects over the place's exec channel: `git push` over `ext::`, with `docker exec` as the transport. No network, no ports, no archives, no shared folders. The host drives every step. The command sequences are the ones in `docs/isolated-spaces/stage-0/e4-git-over-exec.md`, with the changes below. Nothing calls this yet, and getting code out, quarantine and apply are stage 3b.
|
|
@@ -1008,7 +1033,7 @@ Not built: moving a space when the agent finishes its turn. That needs session a
|
|
|
1008
1033
|
|
|
1009
1034
|
The name check sits outside the rollback on purpose. If the id is taken, the resources belong to an existing space and must survive the failed call.
|
|
1010
1035
|
|
|
1011
|
-
After an interrupted step (`command_timeout`, `command_killed`, or `command_output_too_large`) the daemon may still finish the step whose CLI died. `create` then sweeps, waits two seconds, and sweeps again. The second sweep also inspects the seven names directly and removes the ones that exist, labelled or not. This covers a late `docker run` or `docker create`: it makes a missing `src=` volume again, and that volume has no labels. This by-name removal, the two for a tools volume described in "Fill rollback", and the space image a clean-up of the places page asks Docker to remove without force (see "Places page"), are the only places in the module where a resource without our labels can be deleted. It is safe because the same call confirmed all seven names absent moments earlier, and the names hold a random 48-bit id, so nobody else makes them in between. The error carries `details.uncertain: true`, so the caller can tell the user to look at the spaces list.
|
|
1036
|
+
After an interrupted step (`command_timeout`, `command_killed`, or `command_output_too_large`) the daemon may still finish the step whose CLI died. `create` then sweeps, waits two seconds, and sweeps again. The second sweep also inspects the seven names directly and removes the ones that exist, labelled or not. This covers a late `docker run` or `docker create`: it makes a missing `src=` volume again, and that volume has no labels. This by-name removal, the two for a tools volume described in "Fill rollback", and the space image and retired images a clean-up of the places page asks Docker to remove without force (see "Places page"), are the only places in the module where a resource without our labels can be deleted. It is safe because the same call confirmed all seven names absent moments earlier, and the names hold a random 48-bit id, so nobody else makes them in between. The error carries `details.uncertain: true`, so the caller can tell the user to look at the spaces list.
|
|
1012
1037
|
|
|
1013
1038
|
The manager verifies again after `create` and removes a space that reports violations or cannot be inspected. For the Docker place this repeats a check that already passed. It stays because the guarantee must hold for every place, including a place whose `create` forgets to verify.
|
|
1014
1039
|
|
|
@@ -1155,15 +1180,15 @@ Not verified with OpenCode 2.0.15: the rewritten credential test and the rewritt
|
|
|
1155
1180
|
|
|
1156
1181
|
## Base image
|
|
1157
1182
|
|
|
1158
|
-
`SPACE_BASE_IMAGE` in `places/docker.js` is `node:
|
|
1183
|
+
`SPACE_BASE_IMAGE` in `places/docker.js` is `node:24-bookworm` pinned as `node@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0`.
|
|
1159
1184
|
|
|
1160
|
-
It must be the multi-arch index digest, or spaces break on the other CPU architecture. Verified on 2026-
|
|
1185
|
+
It must be the multi-arch index digest, or spaces break on the other CPU architecture. Verified on 2026-10-08:
|
|
1161
1186
|
|
|
1162
1187
|
```
|
|
1163
|
-
docker manifest inspect node@sha256:
|
|
1188
|
+
docker manifest inspect node@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0
|
|
1164
1189
|
```
|
|
1165
1190
|
|
|
1166
|
-
The answer has `mediaType: application/vnd.oci.image.index.v1+json` and lists manifests for `linux/amd64`, `linux/
|
|
1191
|
+
The answer has `mediaType: application/vnd.oci.image.index.v1+json` and lists manifests for `linux/amd64`, `linux/arm64`, and `linux/ppc64le`, plus attestation entries with platform `unknown/unknown`. Node 24 dropped 32-bit `linux/arm`. An arch-specific digest answers with `application/vnd.oci.image.manifest.v1+json` and no platform list. `docker images --digests node` showed the same digest for the `24-bookworm` tag. Repeat both commands when you bump the digest. `docker buildx imagetools inspect` gives the same facts where buildx is installed. A bump also reads the image's own variables again for `hardening.js`, and adds the digest it replaces to `RETIRED_SPACE_BASE_IMAGES`, see "Retired images" under "Places page". The bump of 2026-10-08 from `node:22-bookworm` is the first.
|
|
1167
1192
|
|
|
1168
1193
|
## Tests
|
|
1169
1194
|
|
|
@@ -1179,7 +1204,7 @@ Each live file uses an owner id of its own, removes its spaces, helper container
|
|
|
1179
1204
|
|
|
1180
1205
|
`places/contract-suite.js` is a fixed contract. It passes unchanged against the memory place, the fake Docker, and live Docker.
|
|
1181
1206
|
|
|
1182
|
-
`places/fake-docker.js` answers like the docker CLI for the subcommands the place uses. It knows tools volumes and their fill marker, the three one-shots, container ids, `rename`, `network connect`, a container name that is in use, a volume that is in use, a CLI that cannot reach the daemon and exits with 1, the server inside a space, which answers `/health` once its token is there, and a gatekeeper, whose control channel answers once its program has been written and whose tmpfs is empty again after a stop. Its `wait` moves its own clock, so no test sleeps. `docker.test.js` uses it for tools reuse, fill, fill failure, interrupted fill, a stranger's volume, two creates that share one fill, the readiness timeout, the token that never shows in an argument, a marker check that Docker could not run, a marker for other content, a tools volume that came back without labels, and the move to new tools with its rollbacks, including the old container that got its name back before the create landed.
|
|
1207
|
+
`places/fake-docker.js` answers like the docker CLI for the subcommands the place uses. It knows tools volumes and their fill marker, retired images with their tags, the three one-shots, container ids, `rename`, `network connect`, a container name that is in use, a volume that is in use, a CLI that cannot reach the daemon and exits with 1, the server inside a space, which answers `/health` once its token is there, and a gatekeeper, whose control channel answers once its program has been written and whose tmpfs is empty again after a stop. Its `wait` moves its own clock, so no test sleeps. `docker.test.js` uses it for tools reuse, fill, fill failure, interrupted fill, a stranger's volume, two creates that share one fill, the readiness timeout, the token that never shows in an argument, a marker check that Docker could not run, a marker for other content, a tools volume that came back without labels, and the move to new tools with its rollbacks, including the old container that got its name back before the create landed.
|
|
1183
1208
|
|
|
1184
1209
|
`dispatcher.test.js` runs the dispatcher in front of a stand-in for the server inside: an Express app behind the host's own `createUiAuth`, so the login, the cookie name with the port in it and the refusal of a wrong token are the real ones, reached over a plain socket. It covers the prefix and the credentials coming off and the session going on, one login and one connection pool for many requests, a three-megabyte body streamed in, a form-encoded body kept whole by the parser predicate, an event stream read event by event with the request inside ended when the client leaves, a four-megabyte event stream whole, both guards in every spelling they take with their controls, the host-only and refused routes with their neighbours forwarded, an unknown id and one that appears, the cookie stripped and the page and raw-file content types, a redirect and `Clear-Site-Data` and `Refresh` never leaving the dispatcher for any method, an answer without a content type served as text, a stopped space answered with its code while a three-megabyte body is still on its way in, the renewed session and the repeated request after a 401, the body request that is not repeated, the renewal before expiry, the codes for a stopped space and an unreachable server, and the token absent from every answer and log line when the login fails. Each guard's test was run once with its guard removed and went red; so did the parser predicate's test, where the request hangs without it. `command-stream.test.js` runs `openCommandStream` and the bridge on the host's Node: bytes both ways, the error with the command's stderr, the kill on destroy, the idle timeout, the spawn failure, HTTP over a keep-alive agent with one process for many requests, the four-megabyte full-duplex exchange compared by sha256, `ECONNREFUSED` reported, and the command gone when the host lets go and when the server closes. `host.test.js` covers the owner file and the wiring over the memory place, with no process started, and since 4b, over a stand-in place whose spaces are Express apps behind the real UI auth, the merged list with its drops and the stale mark of a stopped space, the event connections into the real hub with the host's ids learned from it, and a socket forwarded through the mounted upgrade handler. `websocket.test.js` runs the forwarder against such a stand-in with an echo socket: the handshake built without the user's credentials and with the loopback origin, a header with a line break refused, the answer parsed with the bytes after it, the close frame's bytes, the terminal socket forwarded with the query token gone and the space session on, a 4 MiB frame both ways, every refusal before the upgrade with its code, the place's and the space's own refusals, the host's auth and origin rules per socket, the session renewed once after a 401, the close frame when the stream inside dies and the stream killed when the client leaves, and every socket ended at close. `space-sessions.test.js` is the table of what a space may claim: each directory spelling kept or dropped, a host id never taken, an id another space listed dropped, the field allowlist, each event rule, the merge order and marks, and the index's stale, partial, unknown and forgotten states. `space-events.test.js` runs the connection against a stand-in stream and the real hub: the announcement, the status seed, an event entered with the space's id and directory, the three drops with the directory absent from the log, the backoff after the stream ends with the gap announced, the connection dropped for a space that is gone, and a refused connection retried quietly. `places/sessions.docker.live.test.js` is the live file of 4b, see "Verified and not verified".
|
|
1185
1210
|
|
|
@@ -96,6 +96,21 @@ describe('openCommandStream', () => {
|
|
|
96
96
|
expect(stream.timeout).toBe(150);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
it('takes a timeout listener off again when the timeout is cancelled with it, as a socket does', async () => {
|
|
100
|
+
// The http client sets and cancels a timeout with its listener on every request a pooled
|
|
101
|
+
// stream carries; left on, the listeners pile up across the stream's life.
|
|
102
|
+
const stream = open(node, ['-e', ECHO]);
|
|
103
|
+
stream.resume();
|
|
104
|
+
const listener = () => {};
|
|
105
|
+
for (let round = 0; round < 12; round += 1) {
|
|
106
|
+
stream.setTimeout(1_000, listener);
|
|
107
|
+
stream.setTimeout(0, listener);
|
|
108
|
+
}
|
|
109
|
+
expect(stream.listenerCount('timeout')).toBe(0);
|
|
110
|
+
stream.setTimeout(1_000, listener);
|
|
111
|
+
expect(stream.listenerCount('timeout')).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
99
114
|
it('answers a command that cannot start with an error, not an exception', async () => {
|
|
100
115
|
const stream = open('/nonexistent/program/of-ours', []);
|
|
101
116
|
stream.resume();
|
|
@@ -521,6 +521,13 @@ export function createSpaceDispatcher({ transport, now = Date.now, logger = cons
|
|
|
521
521
|
res.on('close', onClientGone);
|
|
522
522
|
|
|
523
523
|
upstreamRequest.on('response', (upstream) => {
|
|
524
|
+
// The client left between the request and the answer, a list poll aborted by its panel
|
|
525
|
+
// among them: nothing to write to, and `pipeline` into a destroyed response throws.
|
|
526
|
+
if (res.destroyed) {
|
|
527
|
+
upstream.destroy();
|
|
528
|
+
finish('done');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
524
531
|
if (upstream.statusCode === 401 && onUnauthorized === 'report') {
|
|
525
532
|
upstream.resume();
|
|
526
533
|
upstream.on('end', () => finish('unauthorized'));
|
|
@@ -86,6 +86,9 @@ const startInside = async ({ password = TOKEN } = {}) => {
|
|
|
86
86
|
res.setHeader('content-type', String(req.query.type));
|
|
87
87
|
res.end('file');
|
|
88
88
|
});
|
|
89
|
+
app.get('/api/slow', (_req, res) => {
|
|
90
|
+
setTimeout(() => res.json({ late: true }), 300);
|
|
91
|
+
});
|
|
89
92
|
app.get('/api/page', (_req, res) => {
|
|
90
93
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
91
94
|
res.send('<script>alert(1)</script>');
|
|
@@ -422,6 +425,20 @@ describe('space dispatcher', () => {
|
|
|
422
425
|
expect(await page.text()).toBe('<script>alert(1)</script>');
|
|
423
426
|
});
|
|
424
427
|
|
|
428
|
+
it('survives a client that leaves between the request and the answer', async () => {
|
|
429
|
+
// The browser panel polls a space's dev servers and aborts the poll when it closes; the
|
|
430
|
+
// answer inside still arrives, and piping it into a response that is gone must not throw.
|
|
431
|
+
await start();
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
const aborted = fetch(host.url(`/api/spaces/${ID}/slow`), { signal: controller.signal }).catch((error) => error);
|
|
434
|
+
await new Promise((resolve) => { setTimeout(resolve, 80); });
|
|
435
|
+
controller.abort();
|
|
436
|
+
expect((await aborted).name).toBe('AbortError');
|
|
437
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
438
|
+
const next = await fetch(host.url(`/api/spaces/${ID}/echo`));
|
|
439
|
+
expect(next.status).toBe(200);
|
|
440
|
+
});
|
|
441
|
+
|
|
425
442
|
it('passes a 304 through: a browser revalidating its own copy is not a redirect', async () => {
|
|
426
443
|
await start();
|
|
427
444
|
const first = await fetch(host.url(`/api/spaces/${ID}/unchanged`));
|
|
@@ -51,9 +51,9 @@ const FILLER_TMPFS_OPTIONS = 'rw,exec,nosuid,size=1g';
|
|
|
51
51
|
// named on its own as well, so its message stays specific.
|
|
52
52
|
const FORBIDDEN_ENVIRONMENT = ['OPENCHAMBER_UI_PASSWORD'];
|
|
53
53
|
// The variables the pinned base image sets itself. Docker copies them into every container made
|
|
54
|
-
// from it. Read on 2026-
|
|
54
|
+
// from it. Read on 2026-10-08 with
|
|
55
55
|
// `docker image inspect <SPACE_BASE_IMAGE> --format '{{json .Config.Env}}'`, which answered
|
|
56
|
-
// PATH, NODE_VERSION=
|
|
56
|
+
// PATH, NODE_VERSION=24.21.0 and YARN_VERSION=1.22.22. Read them again when the digest changes.
|
|
57
57
|
const BASE_IMAGE_ENVIRONMENT_NAMES = ['PATH', 'NODE_VERSION', 'YARN_VERSION'];
|
|
58
58
|
const SPACE_ENVIRONMENT_NAMES = new Set([...Object.keys(SPACE_ENVIRONMENT), ...BASE_IMAGE_ENVIRONMENT_NAMES]);
|
|
59
59
|
const GATEKEEPER_ENVIRONMENT_NAMES = new Set([...Object.keys(GATEKEEPER_ENVIRONMENT), ...BASE_IMAGE_ENVIRONMENT_NAMES]);
|
|
@@ -150,7 +150,7 @@ describe('findHardeningViolations', () => {
|
|
|
150
150
|
it('accepts exactly the variables a space gets and the base image sets', () => {
|
|
151
151
|
const full = withEnv([
|
|
152
152
|
...Object.entries(SPACE_ENVIRONMENT).map(([name, value]) => `${name}=${value}`),
|
|
153
|
-
'NODE_VERSION=
|
|
153
|
+
'NODE_VERSION=24.21.0',
|
|
154
154
|
'YARN_VERSION=1.22.22',
|
|
155
155
|
]);
|
|
156
156
|
expect(checksFor({ container: full })).toEqual([]);
|