@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.
Files changed (442) hide show
  1. package/README.md +32 -24
  2. package/bin/cli.js +1 -3
  3. package/bin/cli.test.js +40 -16
  4. package/bin/lib/DOCUMENTATION.md +6 -5
  5. package/bin/lib/cli-args.js +6 -1
  6. package/bin/lib/cli-args.test.js +34 -0
  7. package/bin/lib/cli-lifecycle.js +0 -1
  8. package/bin/lib/cli-network.js +8 -2
  9. package/bin/lib/cli-process.js +1 -1
  10. package/bin/lib/cli-settings-accessors.test.js +21 -13
  11. package/bin/lib/cli-startup.js +17 -11
  12. package/bin/lib/cli-startup.test.js +92 -2
  13. package/bin/lib/commands-connect-url.js +8 -1
  14. package/bin/lib/commands-schedule.js +8 -2
  15. package/bin/lib/commands-serve.js +15 -21
  16. package/bin/lib/commands-update.js +12 -24
  17. package/bin/lib/commands-update.test.js +14 -46
  18. package/dist/assets/DiffView-Cv9MSXl1.js +4 -0
  19. package/dist/assets/FilePreviewCommentMenu-D4kLseHj.js +4 -0
  20. package/dist/assets/FilesView-BVoD8I2x.js +14 -0
  21. package/dist/assets/{GitView-DzuzhIPx.js → GitView-Bzb8p-ew.js} +4 -4
  22. package/dist/assets/GuestSurfacePane-Crdbvsxo.js +1 -0
  23. package/dist/assets/InProgressOperationBanner-CwMjNQgk.js +2 -0
  24. package/dist/assets/{JsonTreeView-B_EPRjzy.js → JsonTreeView-DRKHZfsn.js} +1 -1
  25. package/dist/assets/MarkdownImageGallery-DZtqT1Zn.js +1 -0
  26. package/dist/assets/MarkdownRendererImpl-CqG7HCNd.js +10 -0
  27. package/dist/assets/OnboardingScreen-DMIxwYK5.js +1 -0
  28. package/dist/assets/{PatchDiff-6yh1csza.js → PatchDiff-CUcCg1jk.js} +1 -1
  29. package/dist/assets/PierreDiffViewer-CnnR-dnD.js +228 -0
  30. package/dist/assets/PlanView-RCBadcY7.js +5 -0
  31. package/dist/assets/SettingsView-CjBjZFRO.js +30 -0
  32. package/dist/assets/SettingsWindow-DyM7QuhI.js +1 -0
  33. package/dist/assets/SubmoduleDiffSummary-BWIfgMED.js +1 -0
  34. package/dist/assets/ToolOutputDialog-Q0P3FuAi.js +15 -0
  35. package/dist/assets/ToolPartDiffPreview-p7qR9FpU.js +8 -0
  36. package/dist/assets/{VirtualizedFileDiff-BTJVRkyo.js → VirtualizedFileDiff-CFpjL8Ns.js} +3 -3
  37. package/dist/assets/{Virtualizer-xCEYz34p.js → Virtualizer-C0IsclZn.js} +1 -1
  38. package/dist/assets/WalkthroughView-DI31zD02.js +4 -0
  39. package/dist/assets/appThemeRegistry-B9gkFq9i.js +1 -0
  40. package/dist/assets/{computeVirtualFileMetrics-CmPe29DM.js → computeVirtualFileMetrics-C2WM6JC2.js} +2 -2
  41. package/dist/assets/de-YKfk5MmC.js +14 -0
  42. package/dist/assets/es-_gwT0kr-.js +20 -0
  43. package/dist/assets/fileTreeStatus-D438yi8M.js +1 -0
  44. package/dist/assets/fr-DqzvXmIm.js +18 -0
  45. package/dist/assets/index-BLaHKdtx.js +1 -0
  46. package/dist/assets/index-BkMXRrVQ.css +1 -0
  47. package/dist/assets/index-DxNa7xYB.js +65 -0
  48. package/dist/assets/initializeSharedPreferences-Bck5EAWP.js +1 -0
  49. package/dist/assets/{isDiffPlainText-CQmDCnEv.js → isDiffPlainText-Dgbw8VKk.js} +1 -1
  50. package/dist/assets/{isFilePlainText-CYBUzW4I.js → isFilePlainText-_ZS5UYRj.js} +1 -1
  51. package/dist/assets/ja-B-GFYhQE.js +20 -0
  52. package/dist/assets/ko-mzksfqDC.js +20 -0
  53. package/dist/assets/main-C5Xx8dn_.js +2 -0
  54. package/dist/assets/main-CfBSQM1H.js +990 -0
  55. package/dist/assets/markdownCore-BmWwW1ll.js +7 -0
  56. package/dist/assets/miniChat-mnKwy4vQ.js +2 -0
  57. package/dist/assets/mobile-DeEQd0wZ.js +2 -0
  58. package/dist/assets/nl-CWQr6NSq.js +20 -0
  59. package/dist/assets/{parseDiffFromFile-Cy0PNxun.js → parseDiffFromFile-BUgn-T9p.js} +2 -2
  60. package/dist/assets/patchFileDiff-Dt8LhgBh.js +13 -0
  61. package/dist/assets/pl-UmRcDUX2.js +20 -0
  62. package/dist/assets/pt-BR-BSLybvVF.js +20 -0
  63. package/dist/assets/relocateRestoredSession-B5E9xb3T.js +1 -0
  64. package/dist/assets/renderElectronMiniChatApp-C0a9A5_J.js +2 -0
  65. package/dist/assets/renderMobileApp-BNncg3Pk.js +25 -0
  66. package/dist/assets/runtimeEndpointReset-CZ5aafpl.js +62 -0
  67. package/dist/assets/{selectionMarkdown-TkYk5h9l.js → selectionMarkdown-CUN3wUld.js} +9 -9
  68. package/dist/assets/{sessionOwnership-Bs1xbRfB.js → sessionOwnership-BSuxCEhb.js} +1 -1
  69. package/dist/assets/shikiHighlight-DHDx-6eM.js +1 -0
  70. package/dist/assets/space-apply-DM_2pRW7.js +1 -0
  71. package/dist/assets/space-close-Bpomt8z0.js +1 -0
  72. package/dist/assets/{surface-B6n7wJtt.js → surface-C-jQaGnL.js} +1 -1
  73. package/dist/assets/{textMateThemeFromAppTheme-CDlKffWz.js → textMateThemeFromAppTheme-DlsTphbY.js} +1 -1
  74. package/dist/assets/tr-gaTrX_HK.js +11 -0
  75. package/dist/assets/uk-CnJF6ykl.js +20 -0
  76. package/dist/assets/{useAppFontEffects-DCqCVGlT.js → useAppFontEffects-DGlvOqP_.js} +194 -233
  77. package/dist/assets/useInlineCommentController-kMN4Wg_D.js +2 -0
  78. package/dist/assets/{useRootScrollLock-BowwS8kq.js → useRootScrollLock-DMPpvpNP.js} +2 -2
  79. package/dist/assets/{vendor-base-ui-DX96QhTP.js → vendor-base-ui-Det7U81h.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-autocomplete-DW1FQz9_.js → vendor-codemirror-autocomplete-BSHQHbKu.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-commands-DpmhalwN.js → vendor-codemirror-commands-C4G_dPWe.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-angular-BqExbN1M.js → vendor-codemirror-lang-angular-B6LbBGHB.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-lang-cpp-DZA36HfS.js → vendor-codemirror-lang-cpp-BWWXBx7W.js} +1 -1
  84. package/dist/assets/{vendor-codemirror-lang-css-B07blnnZ.js → vendor-codemirror-lang-css-Cayfb29q.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-lang-go-BM8K9D1-.js → vendor-codemirror-lang-go-BoKlCNKw.js} +1 -1
  86. package/dist/assets/{vendor-codemirror-lang-html-DaH9adfE.js → vendor-codemirror-lang-html-BQWerHsJ.js} +1 -1
  87. package/dist/assets/{vendor-codemirror-lang-java-CsdC0viz.js → vendor-codemirror-lang-java-DWX7Fq_K.js} +1 -1
  88. package/dist/assets/{vendor-codemirror-lang-javascript-CpGYXGuo.js → vendor-codemirror-lang-javascript-DbpEcZM0.js} +2 -2
  89. package/dist/assets/{vendor-codemirror-lang-jinja-BBeGzXw5.js → vendor-codemirror-lang-jinja-D1JIJRA7.js} +1 -1
  90. package/dist/assets/{vendor-codemirror-lang-json-58T70Hqs.js → vendor-codemirror-lang-json-BzgckzmB.js} +1 -1
  91. package/dist/assets/{vendor-codemirror-lang-less-BI-xCA59.js → vendor-codemirror-lang-less-B9EpDXFi.js} +1 -1
  92. package/dist/assets/{vendor-codemirror-lang-liquid-zEcMa-zy.js → vendor-codemirror-lang-liquid-9U7kRbSf.js} +1 -1
  93. package/dist/assets/{vendor-codemirror-lang-markdown-BLKnlLP4.js → vendor-codemirror-lang-markdown-D2TEBIvZ.js} +1 -1
  94. package/dist/assets/{vendor-codemirror-lang-php-9f35M83-.js → vendor-codemirror-lang-php-BT6sDVzE.js} +1 -1
  95. package/dist/assets/vendor-codemirror-lang-python-jAMtYiA9.js +3 -0
  96. package/dist/assets/{vendor-codemirror-lang-rust-MNDL_jZQ.js → vendor-codemirror-lang-rust-CVO56EiL.js} +1 -1
  97. package/dist/assets/{vendor-codemirror-lang-sass-p9epsSNm.js → vendor-codemirror-lang-sass-DZlOoUWu.js} +1 -1
  98. package/dist/assets/{vendor-codemirror-lang-sql-Bqzh0EXX.js → vendor-codemirror-lang-sql-fZ6t1DPw.js} +1 -1
  99. package/dist/assets/{vendor-codemirror-lang-vue-BnNUWV-p.js → vendor-codemirror-lang-vue-DxbqrLiE.js} +1 -1
  100. package/dist/assets/{vendor-codemirror-lang-wast-C2a-oLmk.js → vendor-codemirror-lang-wast-DyU1Gt-I.js} +1 -1
  101. package/dist/assets/vendor-codemirror-lang-xml-C9a_K5_p.js +1 -0
  102. package/dist/assets/{vendor-codemirror-lang-yaml-C0sjswB0.js → vendor-codemirror-lang-yaml-Cptq8l0e.js} +1 -1
  103. package/dist/assets/{vendor-codemirror-language-ByBn5zIW.js → vendor-codemirror-language-DufXoQvE.js} +2 -2
  104. package/dist/assets/vendor-codemirror-language-data-CtwSWKtX.js +2 -0
  105. package/dist/assets/{vendor-codemirror-search-ib-sHD5W.js → vendor-codemirror-search-CvXM_pZg.js} +1 -1
  106. package/dist/assets/{vendor-codemirror-state-G3UuR-be.js → vendor-codemirror-state-Dek9sMtw.js} +1 -1
  107. package/dist/assets/{vendor-codemirror-view-Bniqp7p5.js → vendor-codemirror-view-1e0FQ6Wn.js} +1 -1
  108. package/dist/assets/vendor-framer-motion-DxDVhOLc.js +9 -0
  109. package/dist/assets/vendor-katex-CuQvj9-L.js +257 -0
  110. package/dist/assets/vendor-katex-DdZNUVNG.css +1 -0
  111. package/dist/assets/{vendor-motion-dom-DcCgeG69.js → vendor-motion-dom-HXPOMxb7.js} +1 -1
  112. package/dist/assets/{vendor-opencode-client-DHzhmLwV.js → vendor-opencode-client-BmFcUkbQ.js} +1 -1
  113. package/dist/assets/{vendor-replit-codemirror-vim-DkzqxN2H.js → vendor-replit-codemirror-vim-BlJRn-cW.js} +2 -2
  114. package/dist/assets/{vendor-tanstack-react-virtual-H_JmXOFV.js → vendor-tanstack-react-virtual-CvuLsk7D.js} +1 -1
  115. package/dist/assets/{vendor-tanstack-virtual-core-Dxc8hSTB.js → vendor-tanstack-virtual-core-DPaNcJcT.js} +1 -1
  116. package/dist/assets/zh-CN-t2vigSB1.js +20 -0
  117. package/dist/assets/zh-TW-jAgqBokB.js +20 -0
  118. package/dist/index.html +6 -6
  119. package/dist/mini-chat.html +6 -6
  120. package/dist/mobile.html +6 -6
  121. package/package.json +8 -5
  122. package/server/index.js +80 -36
  123. package/server/lib/agent-memory/actions.js +1 -6
  124. package/server/lib/agent-memory/routes.js +1 -2
  125. package/server/lib/agent-memory/runtime.js +1 -8
  126. package/server/lib/agent-tool/DOCUMENTATION.md +7 -3
  127. package/server/lib/agent-tool/runtime.js +3 -6
  128. package/server/lib/agent-tool/runtime.test.js +0 -2
  129. package/server/lib/browser-control/DOCUMENTATION.md +6 -0
  130. package/server/lib/changelog/update-notes.js +1 -1
  131. package/server/lib/context-obligatory/runtime.js +1 -2
  132. package/server/lib/dev-tunnel/DOCUMENTATION.md +8 -0
  133. package/server/lib/dev-tunnel/client.js +21 -10
  134. package/server/lib/dev-tunnel/tunnel.test.js +34 -0
  135. package/server/lib/dispatch-results/runtime.js +1 -1
  136. package/server/lib/enterprise-mode.d.ts +1 -0
  137. package/server/lib/enterprise-mode.js +13 -2
  138. package/server/lib/enterprise-mode.test.js +21 -0
  139. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  140. package/server/lib/event-stream/index.js +1 -3
  141. package/server/lib/event-stream/translate-v2.js +1 -9
  142. package/server/lib/event-stream/translate-v2.test.js +0 -11
  143. package/server/lib/fs/DOCUMENTATION.md +5 -2
  144. package/server/lib/fs/routes.js +62 -15
  145. package/server/lib/fs/routes.test.js +278 -48
  146. package/server/lib/fs/workspace-file-names.js +104 -0
  147. package/server/lib/fs/workspace-file-names.test.js +62 -0
  148. package/server/lib/git/DOCUMENTATION.md +17 -5
  149. package/server/lib/git/contributor-provenance-storage.js +9 -6
  150. package/server/lib/git/contributor-provenance-storage.test.js +29 -0
  151. package/server/lib/git/credential-broker.js +1 -1
  152. package/server/lib/git/credential-resolver.js +1 -4
  153. package/server/lib/git/credentials.js +1 -0
  154. package/server/lib/git/discovery-endpoint.js +1 -1
  155. package/server/lib/git/identity-provisioning.js +2 -1
  156. package/server/lib/git/identity-storage.js +3 -4
  157. package/server/lib/git/lfs-discovery.js +14 -14
  158. package/server/lib/git/lfs-discovery.test.js +16 -6
  159. package/server/lib/git/network-operation-plan.js +50 -62
  160. package/server/lib/git/network-operation-plan.test.js +103 -1
  161. package/server/lib/git/network-operation-registry.js +4 -6
  162. package/server/lib/git/network-operation-storage.js +4 -6
  163. package/server/lib/git/network-operations.js +12 -4
  164. package/server/lib/git/network-operations.test.js +19 -6
  165. package/server/lib/git/redaction.js +2 -1
  166. package/server/lib/git/repository-credential-runtime.js +5 -3
  167. package/server/lib/git/repository-credential-runtime.test.js +22 -0
  168. package/server/lib/git/routes.js +31 -137
  169. package/server/lib/git/routes.test.js +2 -2
  170. package/server/lib/git/service.js +143 -27
  171. package/server/lib/git/service.test.js +215 -1
  172. package/server/lib/git/ssh-credential-storage.js +1 -4
  173. package/server/lib/git/submodule-discovery.js +1 -1
  174. package/server/lib/git/worktree-bootstrap-storage.js +3 -4
  175. package/server/lib/git/worktree-bootstrap-storage.test.js +15 -0
  176. package/server/lib/git/worktree-creation.js +194 -0
  177. package/server/lib/git/worktree-creation.test.js +155 -0
  178. package/server/lib/github/DOCUMENTATION.md +11 -6
  179. package/server/lib/github/auth.js +2 -4
  180. package/server/lib/github/device-flow.js +2 -2
  181. package/server/lib/github/gh-cli-credential.js +1 -0
  182. package/server/lib/github/octokit.js +31 -0
  183. package/server/lib/github/pr-status.js +35 -18
  184. package/server/lib/github/pr-status.test.js +271 -3
  185. package/server/lib/github/reference-search.js +102 -13
  186. package/server/lib/github/reference-search.test.js +48 -7
  187. package/server/lib/github/routes.auth.test.js +18 -0
  188. package/server/lib/github/routes.js +360 -6
  189. package/server/lib/github/routes.mutations.test.js +239 -0
  190. package/server/lib/github/routes.test.js +134 -2
  191. package/server/lib/gitlab/DOCUMENTATION.md +5 -1
  192. package/server/lib/gitlab/auth-storage.test.js +1 -0
  193. package/server/lib/gitlab/mappers.js +30 -1
  194. package/server/lib/gitlab/resources.js +230 -6
  195. package/server/lib/gitlab/resources.test.js +179 -0
  196. package/server/lib/gitlab/routes.js +200 -6
  197. package/server/lib/gitlab/routes.test.js +186 -2
  198. package/server/lib/gitlab/validation.js +1 -5
  199. package/server/lib/guests/DOCUMENTATION.md +1 -1
  200. package/server/lib/guests/oauth.js +2 -2
  201. package/server/lib/guests/service.js +1 -1
  202. package/server/lib/guests/sockets.js +1 -1
  203. package/server/lib/injected-env.d.ts +8 -0
  204. package/server/lib/injected-env.js +50 -0
  205. package/server/lib/injected-env.test.js +56 -0
  206. package/server/lib/linear/DOCUMENTATION.md +5 -6
  207. package/server/lib/linear/issues.js +62 -4
  208. package/server/lib/linear/issues.test.js +68 -0
  209. package/server/lib/linear/oauth.js +5 -5
  210. package/server/lib/linear/parse.js +2 -9
  211. package/server/lib/message-queue/DOCUMENTATION.md +23 -1
  212. package/server/lib/message-queue/runtime.js +85 -12
  213. package/server/lib/message-queue/runtime.test.js +172 -2
  214. package/server/lib/notifications/apns-runtime.js +5 -6
  215. package/server/lib/notifications/apns-runtime.test.js +20 -4
  216. package/server/lib/openchamber-control/DOCUMENTATION.md +19 -1
  217. package/server/lib/openchamber-control/actions.js +2 -1
  218. package/server/lib/openchamber-control/service.js +119 -20
  219. package/server/lib/openchamber-control/service.test.js +166 -0
  220. package/server/lib/openchamber-sessions/archive-store.js +1 -6
  221. package/server/lib/openchamber-sessions/routes.js +47 -12
  222. package/server/lib/openchamber-sessions/routes.test.js +65 -2
  223. package/server/lib/openchamber-sessions/session-metadata-store.js +1 -6
  224. package/server/lib/opencode/DOCUMENTATION.md +22 -8
  225. package/server/lib/opencode/agents.js +62 -0
  226. package/server/lib/opencode/auth-state-runtime.js +3 -1
  227. package/server/lib/opencode/auth.d.ts +1 -1
  228. package/server/lib/opencode/auth.js +8 -0
  229. package/server/lib/opencode/auth.test.js +31 -0
  230. package/server/lib/opencode/config-entity-routes.js +16 -0
  231. package/server/lib/opencode/config-entity-v2.test.js +16 -1
  232. package/server/lib/opencode/config-paths.test.js +2 -2
  233. package/server/lib/opencode/config-v2.js +2 -4
  234. package/server/lib/opencode/feature-routes-runtime.js +89 -49
  235. package/server/lib/opencode/lifecycle.js +41 -21
  236. package/server/lib/opencode/lifecycle.test.js +50 -2
  237. package/server/lib/opencode/model-discovery.js +1 -1
  238. package/server/lib/opencode/model-discovery.test.js +17 -0
  239. package/server/lib/opencode/models-metadata.js +0 -2
  240. package/server/lib/opencode/openchamber-routes.test.js +3 -3
  241. package/server/lib/opencode/plugins.js +6 -9
  242. package/server/lib/opencode/provider-source-route.test.js +101 -3
  243. package/server/lib/opencode/providers-disabled.test.js +59 -0
  244. package/server/lib/opencode/providers.js +34 -0
  245. package/server/lib/opencode/proxy.js +11 -3
  246. package/server/lib/opencode/routes.js +40 -2
  247. package/server/lib/opencode/settings-helpers.js +27 -1
  248. package/server/lib/opencode/settings-helpers.test.js +19 -7
  249. package/server/lib/opencode/settings-registry.json +27 -16
  250. package/server/lib/opencode/settings-runtime.test.js +18 -0
  251. package/server/lib/opencode/shared-service-runtime.js +23 -104
  252. package/server/lib/opencode/shared.js +77 -46
  253. package/server/lib/opencode/shared.test.js +12 -12
  254. package/server/lib/opencode/shutdown-runtime.test.js +2 -3
  255. package/server/lib/opencode/skill-routes.js +123 -38
  256. package/server/lib/opencode/skill-routes.test.js +201 -0
  257. package/server/lib/opencode/skills.js +81 -34
  258. package/server/lib/opencode/skills.test.js +176 -21
  259. package/server/lib/opencode/startup-pipeline-runtime.js +6 -3
  260. package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -1
  261. package/server/lib/opencode/watcher.test.js +0 -54
  262. package/server/lib/outbound-proxy.js +52 -0
  263. package/server/lib/outbound-proxy.test.js +55 -0
  264. package/server/lib/package-manager.js +23 -17
  265. package/server/lib/package-manager.test.js +5 -18
  266. package/server/lib/permission-auto-accept/DOCUMENTATION.md +1 -1
  267. package/server/lib/permission-auto-accept/runtime.js +19 -47
  268. package/server/lib/permission-auto-accept/runtime.test.js +2 -2
  269. package/server/lib/project-context/DOCUMENTATION.md +7 -1
  270. package/server/lib/project-context/routes.js +1 -2
  271. package/server/lib/project-context/runtime.js +12 -14
  272. package/server/lib/project-context/runtime.test.js +78 -2
  273. package/server/lib/projects/project-config.js +30 -15
  274. package/server/lib/projects/project-config.test.js +30 -0
  275. package/server/lib/projects/project-setup.js +1 -2
  276. package/server/lib/projects/routes.js +1 -2
  277. package/server/lib/quota/DOCUMENTATION.md +35 -7
  278. package/server/lib/quota/index.js +1 -0
  279. package/server/lib/quota/providers/claude/index.js +1 -1
  280. package/server/lib/quota/providers/cline-pass.js +1 -1
  281. package/server/lib/quota/providers/exe-dev.js +1 -1
  282. package/server/lib/quota/providers/google/index.js +1 -1
  283. package/server/lib/quota/providers/hyper.js +1 -1
  284. package/server/lib/quota/providers/kimi.js +116 -36
  285. package/server/lib/quota/providers/kimi.test.js +128 -3
  286. package/server/lib/quota/providers/opencode-go.js +144 -21
  287. package/server/lib/quota/providers/opencode-go.test.js +229 -6
  288. package/server/lib/quota/providers/openrouter.js +1 -1
  289. package/server/lib/quota/providers/zenmux.js +1 -1
  290. package/server/lib/quota/utils/auth.js +1 -0
  291. package/server/lib/quota/utils/transformers.js +2 -6
  292. package/server/lib/relay/DOCUMENTATION.md +18 -3
  293. package/server/lib/relay/e2ee.js +7 -7
  294. package/server/lib/relay/host-client.js +84 -13
  295. package/server/lib/relay/host-client.test.js +201 -1
  296. package/server/lib/relay/identity.js +6 -29
  297. package/server/lib/relay/identity.test.js +18 -28
  298. package/server/lib/relay/key-store.js +141 -0
  299. package/server/lib/relay/key-store.test.js +138 -0
  300. package/server/lib/relay/service.js +8 -8
  301. package/server/lib/relay/service.test.js +10 -5
  302. package/server/lib/relay/signing-key.js +13 -36
  303. package/server/lib/relay/tunnel-codec.js +10 -10
  304. package/server/lib/route-module-exports.test.js +22 -0
  305. package/server/lib/routing/DOCUMENTATION.md +23 -3
  306. package/server/lib/routing/cache.js +87 -0
  307. package/server/lib/routing/defaults.js +11 -0
  308. package/server/lib/routing/history.js +12 -4
  309. package/server/lib/routing/runtime.js +39 -3
  310. package/server/lib/routing/runtime.test.js +81 -2
  311. package/server/lib/routing/store.js +12 -1
  312. package/server/lib/routing/store.test.js +13 -0
  313. package/server/lib/scheduled-tasks/DOCUMENTATION.md +59 -1
  314. package/server/lib/scheduled-tasks/existing-session.js +119 -0
  315. package/server/lib/scheduled-tasks/existing-session.test.js +350 -0
  316. package/server/lib/scheduled-tasks/loops.js +1 -8
  317. package/server/lib/scheduled-tasks/routes.js +8 -8
  318. package/server/lib/scheduled-tasks/runtime.js +103 -13
  319. package/server/lib/scheduled-tasks/runtime.test.js +48 -0
  320. package/server/lib/scheduled-tasks/service.js +33 -11
  321. package/server/lib/scheduled-tasks/service.test.js +128 -2
  322. package/server/lib/scheduled-tasks/session-defaults.js +27 -0
  323. package/server/lib/scheduled-tasks/session-defaults.test.js +19 -0
  324. package/server/lib/session-assist/context.js +1 -0
  325. package/server/lib/session-folders/routes.js +2 -2
  326. package/server/lib/session-goal/objectives.js +1 -1
  327. package/server/lib/session-knowledge/routes.js +2 -2
  328. package/server/lib/session-knowledge/runtime.js +1 -2
  329. package/server/lib/session-work/DOCUMENTATION.md +39 -14
  330. package/server/lib/session-work/questions.js +31 -2
  331. package/server/lib/session-work/runtime.js +22 -13
  332. package/server/lib/session-work/runtime.test.js +43 -26
  333. package/server/lib/session-work/state.js +10 -4
  334. package/server/lib/session-work/state.test.js +8 -1
  335. package/server/lib/shared/DOCUMENTATION.md +89 -0
  336. package/server/lib/shared/guards.js +53 -0
  337. package/server/lib/shared/guards.test.js +112 -0
  338. package/server/lib/skills-catalog/DOCUMENTATION.md +10 -8
  339. package/server/lib/skills-catalog/cache.js +3 -2
  340. package/server/lib/skills-catalog/curated-sources.js +5 -1
  341. package/server/lib/skills-catalog/git.js +107 -20
  342. package/server/lib/skills-catalog/git.test.js +78 -0
  343. package/server/lib/skills-catalog/install.js +21 -12
  344. package/server/lib/skills-catalog/scan.js +25 -15
  345. package/server/lib/skills-catalog/source.js +26 -1
  346. package/server/lib/skills-catalog/source.test.js +27 -0
  347. package/server/lib/source-control/DOCUMENTATION.md +2 -1
  348. package/server/lib/source-control/audit-storage.js +7 -6
  349. package/server/lib/source-control/audit-storage.test.js +11 -0
  350. package/server/lib/source-control/binding-service.js +2 -5
  351. package/server/lib/source-control/change-request-worktree.js +90 -0
  352. package/server/lib/source-control/change-request-worktree.test.js +121 -0
  353. package/server/lib/source-control/mutation-executor.js +1 -1
  354. package/server/lib/source-control/mutation-storage.js +11 -5
  355. package/server/lib/source-control/provider-instance.js +1 -2
  356. package/server/lib/source-control/repository-identity.js +1 -1
  357. package/server/lib/source-control/routes.js +14 -2
  358. package/server/lib/spaces/DOCUMENTATION.md +42 -17
  359. package/server/lib/spaces/command-stream.test.js +15 -0
  360. package/server/lib/spaces/dispatcher.js +7 -0
  361. package/server/lib/spaces/dispatcher.test.js +17 -0
  362. package/server/lib/spaces/hardening.js +2 -2
  363. package/server/lib/spaces/hardening.test.js +1 -1
  364. package/server/lib/spaces/host.js +2 -0
  365. package/server/lib/spaces/journey.js +66 -5
  366. package/server/lib/spaces/journey.test.js +95 -4
  367. package/server/lib/spaces/layout.js +1 -1
  368. package/server/lib/spaces/places/docker-disk.js +38 -14
  369. package/server/lib/spaces/places/docker-disk.test.js +80 -1
  370. package/server/lib/spaces/places/docker.js +35 -11
  371. package/server/lib/spaces/places/fake-docker.js +22 -3
  372. package/server/lib/spaces/places/memory-place.js +3 -0
  373. package/server/lib/spaces/places/packed.docker.live.test.js +1 -1
  374. package/server/lib/spaces/routes.js +5 -0
  375. package/server/lib/spaces/routes.test.js +4 -2
  376. package/server/lib/spaces/run-command.js +9 -2
  377. package/server/lib/spaces/tools.js +4 -5
  378. package/server/lib/spaces/tools.test.js +2 -2
  379. package/server/lib/terminal/runtime.js +18 -6
  380. package/server/lib/terminal/runtime.test.js +66 -0
  381. package/server/lib/tts/index.js +3 -0
  382. package/server/lib/tts/service.js +4 -2
  383. package/server/lib/walkthrough/DOCUMENTATION.md +3 -1
  384. package/server/lib/walkthrough/index.js +2 -3
  385. package/server/lib/walkthrough/pr-source.test.js +22 -0
  386. package/server/lib/walkthrough/sources.js +6 -1
  387. package/server/opencode-proxy.test.js +127 -0
  388. package/bin/lib/commands-startup.test.js +0 -15
  389. package/dist/assets/CodeMirrorCommentWidgets-CVDSjcrP.js +0 -1
  390. package/dist/assets/DiffView-Ct7vL3fD.js +0 -4
  391. package/dist/assets/FilesView-DT0uEYgT.js +0 -17
  392. package/dist/assets/GuestSurfacePane-DVvk1VPu.js +0 -1
  393. package/dist/assets/LinearIssuesView-C10MwA9h.js +0 -1
  394. package/dist/assets/MarkdownImageGallery-BbN_K_3d.js +0 -1
  395. package/dist/assets/MarkdownRendererImpl-BLt2Tkk9.js +0 -10
  396. package/dist/assets/OnboardingScreen-Bgp5A3WB.js +0 -1
  397. package/dist/assets/PierreDiffViewer-CWJLj-S-.js +0 -210
  398. package/dist/assets/PlanView-DsRWnsOI.js +0 -5
  399. package/dist/assets/PublishDialog-CCeKloRu.js +0 -2
  400. package/dist/assets/SettingsView-BuPPsNPp.js +0 -30
  401. package/dist/assets/SettingsWindow-CM5fL8Ot.js +0 -1
  402. package/dist/assets/SubmoduleDiffSummary-CfsMcfiQ.js +0 -1
  403. package/dist/assets/ToolOutputDialog-mZrfSofz.js +0 -15
  404. package/dist/assets/ToolPartDiffPreview-BUGCRYEL.js +0 -8
  405. package/dist/assets/WalkthroughView-ClBo7PPd.js +0 -4
  406. package/dist/assets/appThemeRegistry-CxzMxR-H.js +0 -1
  407. package/dist/assets/de-CBla4hjd.js +0 -14
  408. package/dist/assets/es-BmnZtxTD.js +0 -20
  409. package/dist/assets/fileTreeStatus-BSHFIGEg.js +0 -1
  410. package/dist/assets/fr-OVKIBUZO.js +0 -18
  411. package/dist/assets/index-CKCFKmjF.js +0 -1
  412. package/dist/assets/index-Dbx4V4GN.css +0 -1
  413. package/dist/assets/index-vQ1Pg1cr.js +0 -61
  414. package/dist/assets/ja-6v-yr-gE.js +0 -20
  415. package/dist/assets/ko-I_ZLvgQ9.js +0 -20
  416. package/dist/assets/main-CmO4dRtR.js +0 -2
  417. package/dist/assets/main-b9vqhlYM.js +0 -999
  418. package/dist/assets/markdownCore-BPEZRQfP.js +0 -7
  419. package/dist/assets/miniChat-B5Oi7B-6.js +0 -2
  420. package/dist/assets/mobile-CVtShif9.js +0 -2
  421. package/dist/assets/nl-DcvIJf46.js +0 -20
  422. package/dist/assets/patchFileDiff-CW3wCH1O.js +0 -13
  423. package/dist/assets/pl-JxCUl0sd.js +0 -20
  424. package/dist/assets/pt-BR-L89mjja2.js +0 -20
  425. package/dist/assets/relocateRestoredSession-DYh_9oBa.js +0 -1
  426. package/dist/assets/renderElectronMiniChatApp-BD-1JHSg.js +0 -2
  427. package/dist/assets/renderMobileApp-7s5InXt1.js +0 -25
  428. package/dist/assets/runtimeEndpointReset-DqLJC6L1.js +0 -53
  429. package/dist/assets/shikiHighlight-CXgZnvHh.js +0 -1
  430. package/dist/assets/space-apply-1trGVIl8.js +0 -1
  431. package/dist/assets/space-close-y7j-NuBv.js +0 -1
  432. package/dist/assets/tr-ka5J1flt.js +0 -11
  433. package/dist/assets/uk-PyGe6Q_u.js +0 -20
  434. package/dist/assets/useInlineCommentController-CrjSGCpi.js +0 -2
  435. package/dist/assets/vendor-codemirror-lang-python-B7ClrMIT.js +0 -3
  436. package/dist/assets/vendor-codemirror-lang-xml-C8tGnS64.js +0 -1
  437. package/dist/assets/vendor-codemirror-language-data-q2bKO1EM.js +0 -2
  438. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +0 -1
  439. package/dist/assets/vendor-katex-CHD9BdDO.js +0 -257
  440. package/dist/assets/vendor-katex-Cp579UoJ.css +0 -1
  441. package/dist/assets/zh-CN-B0P2tybJ.js +0 -20
  442. package/dist/assets/zh-TW-Bs9C5cN8.js +0 -20
@@ -54,8 +54,9 @@ const scheduleDiskWrite = () => {
54
54
  }
55
55
  };
56
56
 
57
- export function getCacheKey({ normalizedRepo, subpath, identityId }) {
58
- const safeRepo = String(normalizedRepo || '').trim();
57
+ export function getCacheKey({ normalizedRepo, ref, subpath, identityId }) {
58
+ // Branches of one repository are different catalogs.
59
+ const safeRepo = `${String(normalizedRepo || '').trim()}${ref ? `#${ref}` : ''}`;
59
60
  const safeSubpath = String(subpath || '').trim();
60
61
  const safeIdentity = String(identityId || '').trim();
61
62
  return `${safeRepo}::${safeSubpath}::${safeIdentity}`;
@@ -1,3 +1,5 @@
1
+ import { readEnterprisePolicy } from '../enterprise-mode.js';
2
+
1
3
  const CURATED_SKILLS_SOURCES = [
2
4
  {
3
5
  id: 'anthropic',
@@ -35,6 +37,8 @@ const CURATED_SKILLS_SOURCES = [
35
37
  },
36
38
  ];
37
39
 
38
- export function getCuratedSkillsSources() {
40
+ /** The built-in catalogs, or none when the machine policy hides them. */
41
+ export function getCuratedSkillsSources(policyOptions) {
42
+ if (readEnterprisePolicy(policyOptions).hideBuiltinSkillCatalogs) return [];
39
43
  return CURATED_SKILLS_SOURCES.slice();
40
44
  }
@@ -1,11 +1,44 @@
1
1
  import { execFile } from 'child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { promisify } from 'util';
4
+ import { createGitCredentialBroker, GIT_CREDENTIAL_NONCE_ENV } from '../git/credential-broker.js';
5
+ import { normalizeGitRemoteEndpoint } from '../git/credential-resolver.js';
6
+ import { managedSshCommand } from '../git/network-operations.js';
3
7
 
4
8
  const execFileAsync = promisify(execFile);
5
9
 
6
10
  const DEFAULT_TIMEOUT_MS = 60_000;
7
11
  const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
8
12
 
13
+ const isolatedGitEnvironment = (allowedProtocol) => {
14
+ const env = { ...process.env };
15
+ for (const name of Object.keys(env)) {
16
+ if (/^GIT_CONFIG(?:_|$)/i.test(name)
17
+ || /^GIT_TRACE(?:2)?(?:_|$)/i.test(name)
18
+ || /^(?:GIT_ASKPASS|SSH_ASKPASS|SSH_ASKPASS_REQUIRE|GIT_SSH|GIT_SSH_COMMAND|GIT_SSH_VARIANT|GIT_PROXY_COMMAND|SSH_AUTH_SOCK|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy|NETRC|CURL_CA_BUNDLE|SSL_CERT_FILE|SSL_CERT_DIR)$/i.test(name)) {
19
+ delete env[name];
20
+ }
21
+ if (/^(?:GIT_CURL_VERBOSE|GIT_REDIRECT_STDERR|GIT_SSL_CERT|GIT_SSL_KEY|GIT_SSL_CAINFO|GIT_SSL_CAPATH|GIT_SSL_CIPHER_LIST|GIT_SSL_VERSION|GIT_SSL_NO_VERIFY|GIT_SSL_CERT_PASSWORD_PROTECTED|SSLKEYLOGFILE)$/i.test(name)
22
+ || /^CURL_/i.test(name)) {
23
+ delete env[name];
24
+ }
25
+ }
26
+ env.GIT_TERMINAL_PROMPT = '0';
27
+ env.GIT_CONFIG_NOSYSTEM = '1';
28
+ env.GIT_CONFIG_GLOBAL = process.platform === 'win32' ? 'NUL' : '/dev/null';
29
+ env.GIT_ALLOW_PROTOCOL = allowedProtocol;
30
+ if (allowedProtocol === 'https') {
31
+ // Git's Curl transport also consults netrc outside Git configuration; an
32
+ // ambient ~/.netrc entry must not authenticate as another account than
33
+ // the selected one. SSH keeps HOME for known_hosts.
34
+ for (const name of Object.keys(env)) {
35
+ if (['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME', 'HOMEDRIVE', 'HOMEPATH'].includes(name.toUpperCase())) delete env[name];
36
+ }
37
+ env.HOME = env.USERPROFILE = env.XDG_CONFIG_HOME = process.platform === 'win32' ? 'NUL' : '/dev/null';
38
+ }
39
+ return env;
40
+ };
41
+
9
42
  export function looksLikeAuthError(message) {
10
43
  const text = String(message || '');
11
44
  return (
@@ -13,6 +46,7 @@ export function looksLikeAuthError(message) {
13
46
  /publickey/i.test(text) ||
14
47
  /could not read from remote repository/i.test(text) ||
15
48
  /authentication failed/i.test(text) ||
49
+ /selected git identity/i.test(text) ||
16
50
  /fatal: could not/i.test(text)
17
51
  );
18
52
  }
@@ -25,23 +59,72 @@ export async function runGit(args, options = {}) {
25
59
  const identity = options.identity || null;
26
60
  const normalizedArgs = Array.isArray(args) ? args.slice() : [];
27
61
 
28
- // Non-interactive git (avoid prompts / hangs)
29
- const env = {
30
- ...process.env,
31
- GIT_TERMINAL_PROMPT: '0',
32
- };
62
+ let env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
63
+ let credential;
64
+ let broker;
65
+ let lease;
33
66
 
34
- if (identity?.sshKey) {
35
- const sshKeyPath = String(identity.sshKey).trim();
36
- if (sshKeyPath) {
37
- // Avoid interactive host key prompts; still safe against changed keys.
38
- const sshCommand = `ssh -i ${sshKeyPath} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
39
- normalizedArgs.unshift(`core.sshCommand=${sshCommand}`);
40
- normalizedArgs.unshift('-c');
67
+ try {
68
+ if (identity?.credentialId) {
69
+ if (!options.credentialResolver || !identity.endpoint) {
70
+ return { ok: false, stdout: '', stderr: '', message: 'Selected Git identity is unavailable' };
71
+ }
72
+
73
+ const operationId = randomUUID();
74
+ try {
75
+ credential = await options.credentialResolver.resolve({
76
+ mode: 'managed',
77
+ credentialId: identity.credentialId,
78
+ endpoint: normalizeGitRemoteEndpoint(identity.endpoint),
79
+ operationId,
80
+ deadline: Date.now() + timeoutMs,
81
+ });
82
+ } catch {
83
+ return { ok: false, stdout: '', stderr: '', message: 'Authentication failed for the selected Git identity' };
84
+ }
85
+
86
+ if (credential.transport === 'https') {
87
+ broker = createGitCredentialBroker({ capacity: 1, ttlMs: timeoutMs });
88
+ await broker.start();
89
+ lease = broker.issue({ operationId, credential });
90
+ env = isolatedGitEnvironment('https');
91
+ env[GIT_CREDENTIAL_NONCE_ENV] = lease.env[GIT_CREDENTIAL_NONCE_ENV];
92
+ normalizedArgs.unshift(
93
+ '-c', 'credential.helper=',
94
+ '-c', 'credential.useHttpPath=true',
95
+ '-c', 'http.proxy=',
96
+ '-c', 'http.sslVerify=true',
97
+ '-c', 'http.followRedirects=false',
98
+ '-c', 'http.extraHeader=',
99
+ '-c', 'http.cookieFile=',
100
+ '-c', 'credential.interactive=false',
101
+ '-c', 'submodule.recurse=false',
102
+ '-c', 'fetch.recurseSubmodules=false',
103
+ ...lease.gitConfigArgs,
104
+ );
105
+ } else if (credential.transport === 'ssh') {
106
+ env = isolatedGitEnvironment('ssh');
107
+ normalizedArgs.unshift('-c', `core.sshCommand=${managedSshCommand(credential.key.privateKeyPath)}`);
108
+ } else {
109
+ return { ok: false, stdout: '', stderr: '', message: 'Selected Git identity cannot authenticate this repository' };
110
+ }
111
+ } else if (identity?.anonymous) {
112
+ env = isolatedGitEnvironment('https');
113
+ normalizedArgs.unshift(
114
+ '-c', 'credential.helper=',
115
+ '-c', 'http.proxy=',
116
+ '-c', 'http.followRedirects=false',
117
+ '-c', 'http.extraHeader=',
118
+ '-c', 'http.cookieFile=',
119
+ '-c', 'credential.interactive=false',
120
+ '-c', 'submodule.recurse=false',
121
+ '-c', 'fetch.recurseSubmodules=false',
122
+ );
123
+ } else if (identity) {
124
+ // A selected identity that names no credential never falls back to System Git.
125
+ return { ok: false, stdout: '', stderr: '', message: 'Selected Git identity is unavailable' };
41
126
  }
42
- }
43
127
 
44
- try {
45
128
  const { stdout, stderr } = await execFileAsync('git', normalizedArgs, {
46
129
  cwd,
47
130
  env,
@@ -52,19 +135,23 @@ export async function runGit(args, options = {}) {
52
135
 
53
136
  return { ok: true, stdout: stdout || '', stderr: stderr || '' };
54
137
  } catch (error) {
55
- const err = error;
56
- const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
57
- const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
58
- const message = err instanceof Error ? err.message : String(err);
138
+ const err = error instanceof Error ? error : new Error(String(error));
139
+ const stdout = String(err.stdout ?? '');
140
+ const stderr = String(err.stderr ?? '');
141
+ const message = err.message;
59
142
 
60
143
  return {
61
144
  ok: false,
62
145
  stdout,
63
146
  stderr,
64
147
  message,
65
- code: typeof err?.code === 'number' ? err.code : null,
66
- signal: typeof err?.signal === 'string' ? err.signal : null,
148
+ code: Number.isInteger(err.code) ? err.code : null,
149
+ signal: err.signal == null ? null : String(err.signal),
67
150
  };
151
+ } finally {
152
+ lease?.revoke();
153
+ if (credential?.transport === 'ssh') await credential.key.cleanup();
154
+ if (broker) await broker.close();
68
155
  }
69
156
  }
70
157
 
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { runGit } from './git.js';
3
+
4
+ const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
5
+ // A shell alias makes Git print the environment it runs under.
6
+ const PRINT_HOME = ['-c', 'alias.print-home=!printf %s "$HOME"', 'print-home'];
7
+
8
+ const httpsResolver = (password = 'catalog-private-token') => ({
9
+ resolve: vi.fn(async ({ endpoint }) => ({
10
+ mode: 'managed',
11
+ transport: 'https',
12
+ username: 'x-access-token',
13
+ password,
14
+ allowedEndpoint: endpoint,
15
+ })),
16
+ });
17
+
18
+ const httpsIdentity = {
19
+ credentialId: 'managed-account-reference',
20
+ endpoint: 'https://example.com/owner/private.git',
21
+ };
22
+
23
+ describe('skills catalog runGit', () => {
24
+ it('configures managed HTTPS Git through the broker without exposing the token', async () => {
25
+ const password = 'catalog-private-token';
26
+ const credentialResolver = httpsResolver(password);
27
+
28
+ const result = await runGit(['config', '--get', 'credential.helper'], { identity: httpsIdentity, credentialResolver });
29
+
30
+ expect(result.ok).toBe(true);
31
+ expect(result.stdout).toContain('credential-helper.js');
32
+ expect(result.stdout).not.toContain(password);
33
+ expect(result.stderr).not.toContain(password);
34
+ expect(credentialResolver.resolve).toHaveBeenCalledOnce();
35
+ });
36
+
37
+ it('runs managed SSH Git through the key wrapper and removes the key afterwards', async () => {
38
+ const cleanup = vi.fn(async () => {});
39
+ const credentialResolver = {
40
+ resolve: vi.fn(async () => ({
41
+ mode: 'managed',
42
+ transport: 'ssh',
43
+ key: { privateKeyPath: '/tmp/catalog-operation-key', cleanup },
44
+ })),
45
+ };
46
+
47
+ const result = await runGit(['config', '--get', 'core.sshCommand'], {
48
+ identity: { transport: 'ssh', credentialId: 'ssh-key-reference', endpoint: 'git@example.com:owner/private.git' },
49
+ credentialResolver,
50
+ });
51
+
52
+ expect(result.ok).toBe(true);
53
+ expect(result.stdout).toContain('ssh-wrapper.js');
54
+ expect(result.stdout).toContain('/tmp/catalog-operation-key');
55
+ expect(cleanup).toHaveBeenCalledOnce();
56
+ });
57
+
58
+ it('hides the home directory from managed HTTPS Git so ~/.netrc cannot sign in', async () => {
59
+ const result = await runGit(PRINT_HOME, { identity: httpsIdentity, credentialResolver: httpsResolver() });
60
+
61
+ expect(result.ok).toBe(true);
62
+ expect(result.stdout).toBe(NULL_DEVICE);
63
+ });
64
+
65
+ it('hides the home directory from anonymous Git', async () => {
66
+ const result = await runGit(PRINT_HOME, { identity: { anonymous: true, transport: 'anonymous' } });
67
+
68
+ expect(result.ok).toBe(true);
69
+ expect(result.stdout).toBe(NULL_DEVICE);
70
+ });
71
+
72
+ it('refuses a selected identity without a credential instead of using System Git', async () => {
73
+ const result = await runGit(['--version'], { identity: { transport: 'ssh', endpoint: 'git@example.com:owner/private.git' } });
74
+
75
+ expect(result.ok).toBe(false);
76
+ expect(result.message).toBe('Selected Git identity is unavailable');
77
+ });
78
+ });
@@ -90,14 +90,20 @@ async function copyDirectoryNoSymlinks(srcDir, dstDir) {
90
90
  await walk(srcDir, dstDir);
91
91
  }
92
92
 
93
- async function cloneRepo({ cloneUrl, identity, tempDir }) {
94
- const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
95
- const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
96
-
97
- const result = await runGit(preferred, { identity, timeoutMs: 90_000 });
93
+ async function cloneRepo({ cloneUrl, identity, credentialResolver, tempDir, ref = null }) {
94
+ // A `#ref` on the source picks the branch or tag; otherwise the default branch.
95
+ const branch = ref ? ['--branch', ref] : [];
96
+ const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', ...branch, cloneUrl, tempDir];
97
+ const fallback = ['clone', '--depth', '1', '--no-checkout', ...branch, cloneUrl, tempDir];
98
+
99
+ const options = { identity, credentialResolver, timeoutMs: 90_000 };
100
+ const result = await runGit(preferred, options);
98
101
  if (result.ok) return { ok: true };
102
+ if (looksLikeAuthError(`${result.stderr || ''}\n${result.message || ''}`)) {
103
+ return { ok: false, error: result };
104
+ }
99
105
 
100
- const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 });
106
+ const fallbackResult = await runGit(fallback, options);
101
107
  if (fallbackResult.ok) return { ok: true };
102
108
 
103
109
  return {
@@ -132,6 +138,7 @@ export async function installSkillsFromRepository({
132
138
  subpath,
133
139
  defaultSubpath,
134
140
  identity,
141
+ credentialResolver,
135
142
  scope,
136
143
  targetSource,
137
144
  workingDirectory,
@@ -174,7 +181,7 @@ export async function installSkillsFromRepository({
174
181
  const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
175
182
  void effectiveSubpath;
176
183
 
177
- const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
184
+ const cloneUrl = identity?.transport === 'ssh' ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
178
185
 
179
186
  const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : [];
180
187
  if (requestedDirs.length === 0) {
@@ -217,23 +224,25 @@ export async function installSkillsFromRepository({
217
224
  const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-install-'));
218
225
 
219
226
  try {
220
- const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
227
+ const cloned = await cloneRepo({ cloneUrl, identity, credentialResolver, tempDir: tempBase, ref: parsed.ref });
221
228
  if (!cloned.ok) {
222
229
  const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
223
230
  if (looksLikeAuthError(msg)) {
224
- return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
231
+ return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository' } };
225
232
  }
226
233
  return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
227
234
  }
228
235
 
229
236
  // Selective checkout for only requested skill dirs.
230
- await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 });
231
- const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 });
237
+ await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { timeoutMs: 15_000 });
238
+ const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { timeoutMs: 30_000 });
232
239
  if (!setResult.ok) {
233
240
  return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
234
241
  }
235
242
 
236
- const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
243
+ // The checkout fetches the selected blobs from the partial clone, so it
244
+ // authenticates like the clone did.
245
+ const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, credentialResolver, timeoutMs: 60_000 });
237
246
  if (!checkoutResult.ok) {
238
247
  return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
239
248
  }
@@ -45,14 +45,20 @@ async function safeRm(dir) {
45
45
  }
46
46
  }
47
47
 
48
- async function cloneRepo({ cloneUrl, identity, tempDir }) {
49
- const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
50
- const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
51
-
52
- const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
48
+ async function cloneRepo({ cloneUrl, identity, credentialResolver, tempDir, ref = null }) {
49
+ // A `#ref` on the source picks the branch or tag; otherwise the default branch.
50
+ const branch = ref ? ['--branch', ref] : [];
51
+ const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', ...branch, cloneUrl, tempDir];
52
+ const fallback = ['clone', '--depth', '1', '--no-checkout', ...branch, cloneUrl, tempDir];
53
+
54
+ const options = { identity, credentialResolver, timeoutMs: 60_000 };
55
+ const result = await runGit(preferred, options);
53
56
  if (result.ok) return { ok: true };
57
+ if (looksLikeAuthError(`${result.stderr || ''}\n${result.message || ''}`)) {
58
+ return { ok: false, error: result };
59
+ }
54
60
 
55
- const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
61
+ const fallbackResult = await runGit(fallback, options);
56
62
  if (fallbackResult.ok) return { ok: true };
57
63
 
58
64
  return {
@@ -66,6 +72,7 @@ export async function scanSkillsRepository({
66
72
  subpath,
67
73
  defaultSubpath,
68
74
  identity,
75
+ credentialResolver,
69
76
  } = {}) {
70
77
  const gitCheck = await assertGitAvailable();
71
78
  if (!gitCheck.ok) {
@@ -78,16 +85,19 @@ export async function scanSkillsRepository({
78
85
  }
79
86
 
80
87
  const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
81
- const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
88
+ const cloneUrl = identity?.transport === 'ssh' ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
89
+ // Only commands that reach the remote get the identity: the clone, and the
90
+ // checkout or `show` that lazily fetch blobs from the partial clone.
91
+ const remoteGitOptions = { identity, credentialResolver };
82
92
 
83
93
  const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-scan-'));
84
94
 
85
95
  try {
86
- const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
96
+ const cloned = await cloneRepo({ cloneUrl, identity, credentialResolver, tempDir: tempBase, ref: parsed.ref });
87
97
  if (!cloned.ok) {
88
98
  const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
89
99
  if (looksLikeAuthError(msg)) {
90
- return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
100
+ return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository' } };
91
101
  }
92
102
  return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
93
103
  }
@@ -102,13 +112,13 @@ export async function scanSkillsRepository({
102
112
 
103
113
  // Fast path: sparse checkout only SKILL.md files, then parse from disk.
104
114
  // This avoids one `git show` per skill.
105
- const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
115
+ const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { timeoutMs: 15_000 });
106
116
  if (sparseInit.ok) {
107
- const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
117
+ const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { timeoutMs: 30_000 });
108
118
  if (sparseSet.ok) {
109
- const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
119
+ const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { ...remoteGitOptions, timeoutMs: 60_000 });
110
120
  if (checkout.ok) {
111
- const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
121
+ const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { timeoutMs: 15_000 });
112
122
  if (lsFiles.ok) {
113
123
  skillMdPaths = lsFiles.stdout
114
124
  .split(/\r?\n/)
@@ -127,7 +137,7 @@ export async function scanSkillsRepository({
127
137
  listArgs.push('--', effectiveSubpath);
128
138
  }
129
139
 
130
- const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
140
+ const listResult = await runGit(listArgs, { timeoutMs: 30_000 });
131
141
  if (!listResult.ok) {
132
142
  // If subpath doesn't exist, treat as empty scan.
133
143
  return {
@@ -172,7 +182,7 @@ export async function scanSkillsRepository({
172
182
  try {
173
183
  skillMdContent = await fs.promises.readFile(filePath, 'utf8');
174
184
  } catch {
175
- const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
185
+ const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { ...remoteGitOptions, timeoutMs: 15_000 });
176
186
  if (!showResult.ok) {
177
187
  warnings.push('Failed to read SKILL.md');
178
188
  } else {
@@ -11,8 +11,33 @@ function normalizeGitOwnerRepo(owner, repo) {
11
11
  }
12
12
 
13
13
 
14
+ // A branch or tag after `#`: letters, digits and `._/-`, never starting with
15
+ // `-` (it becomes a git argument) and never with `..`.
16
+ const GIT_REF_PATTERN = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/;
17
+
18
+ /**
19
+ * `owner/repo#ref`, `https://host/owner/repo.git#ref`: the source without the
20
+ * fragment, and the branch or tag it names (null when none).
21
+ */
22
+ function splitSourceRef(source) {
23
+ const hashIndex = source.lastIndexOf('#');
24
+ if (hashIndex < 0) return { base: source, ref: null };
25
+ const ref = source.slice(hashIndex + 1).trim();
26
+ return { base: source.slice(0, hashIndex).trim(), ref: ref || null };
27
+ }
28
+
14
29
  export function parseSkillRepoSource(input, options = {}) {
15
- const raw = typeof input === 'string' ? input.trim() : '';
30
+ const parsed = parseSkillRepoBase(input, options);
31
+ if (!parsed.ok) return parsed;
32
+ const { ref } = splitSourceRef(typeof input === 'string' ? input.trim() : '');
33
+ if (ref !== null && !GIT_REF_PATTERN.test(ref)) {
34
+ return { ok: false, error: { kind: 'invalidSource', message: `Invalid branch or tag "${ref}"` } };
35
+ }
36
+ return { ...parsed, ref };
37
+ }
38
+
39
+ function parseSkillRepoBase(input, options) {
40
+ const raw = splitSourceRef(typeof input === 'string' ? input.trim() : '').base;
16
41
  if (!raw) {
17
42
  return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
18
43
  }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseSkillRepoSource } from './source.js';
4
+
5
+ describe('parseSkillRepoSource', () => {
6
+ it('reads a branch or tag after #, from shorthand and URLs', () => {
7
+ expect(parseSkillRepoSource('acme/skills#develop')).toMatchObject({ ok: true, normalizedRepo: 'acme/skills', ref: 'develop', effectiveSubpath: null });
8
+ expect(parseSkillRepoSource('acme/skills/pack#feature/v2')).toMatchObject({ ok: true, normalizedRepo: 'acme/skills', ref: 'feature/v2', effectiveSubpath: 'pack' });
9
+ expect(parseSkillRepoSource('https://github.com/acme/skills.git#v1.0.0')).toMatchObject({
10
+ ok: true,
11
+ cloneUrlHttps: 'https://github.com/acme/skills.git',
12
+ ref: 'v1.0.0',
13
+ });
14
+ expect(parseSkillRepoSource('git@gitlab.acme.dev:team/skills.git#beta')).toMatchObject({ ok: true, host: 'gitlab.acme.dev', ref: 'beta' });
15
+ });
16
+
17
+ it('has no ref without #, or with an empty one', () => {
18
+ expect(parseSkillRepoSource('acme/skills').ref).toBeNull();
19
+ expect(parseSkillRepoSource('acme/skills#').ref).toBeNull();
20
+ });
21
+
22
+ it('refuses a ref git would read as an option or a range', () => {
23
+ for (const source of ['acme/skills#--upload-pack=x', 'acme/skills#a..b', 'acme/skills#x y']) {
24
+ expect(parseSkillRepoSource(source), source).toMatchObject({ ok: false, error: { kind: 'invalidSource' } });
25
+ }
26
+ });
27
+ });
@@ -17,13 +17,14 @@ Repository bindings intentionally keep provider accounts, parent Git transport c
17
17
  - `binding-contract.js` owns the dependency-free strict persisted binding parser, separate binding-response parser, readiness projection, aggregate state, and public endpoint validation for the server and the web adapter. The persisted parser rejects response-only credential presentation, so `source-control-bindings.json` remains references-only. The response parser accepts only the tagged safe presentation contract and rejects unknown fields. Safe endpoint metadata accepts credential-free HTTP/HTTPS, SSH URL, and SCP display forms plus bounded base64url fingerprints; local-file, credential-bearing HTTPS, query, fragment, traversal, control-character, and unsafe fingerprint forms fail closed. Each provider stores its approved fetch endpoint and readiness. Each remote stores readiness alongside its exact fetch/push endpoints; auxiliary grants have their own readiness. Only `ready` grants authority. `account-unavailable`, `confirmation-required`, and `config-changed` retain repair metadata without granting the affected capability. Binding `state` is an aggregate UI summary, never the authority gate.
18
18
  - `mutation-storage.js` owns strict versioned persistence for bounded source-control mutation records in `source-control-mutations.json` under the server config root. `claim` persists an idempotency key before provider work, exact key-and-digest replays return the stored record, and digest changes report a conflict. `complete` permits one transition from `running` to a terminal state. Failed records may retain only an HTTP status and stable code for replay; messages and provider payloads are rejected. Terminal records have a seven-day retention by default and may be evicted oldest-first at capacity; `running` and `outcome-unknown` records are never pruned or evicted. Snapshots use serialized atomic mode-`0600` writes and reject unknown fields, provider payloads, private text, tokens, and raw URLs.
19
19
  - `mutation-executor.js` canonicalizes and hashes mutation input, joins exact process-local duplicates, claims persisted state before provider work, and never retries a provider mutation. Provider routes include the exact resolved credential account ID and revision in this digest and keep the credential account ID in durable replay authority. They pass the verified provider-user ID separately for receipt actor metadata and audit `providerAccountId`; clients cannot supply it. Its store must provide `withExecutionLock(key, operation)`, scoped to that store's file path and the SHA-256 digest of the key, not the input digest. The executor holds this separate lock from before claim through provider work or reconciliation and mutation/audit completion. A second daemon waits or fails busy, never reconciles live work. Completed requests replay their compact receipt after acquiring the lock. An unlocked persisted `running` record enters provider reconciliation; a crashed owner's leftover lock is reclaimed by the next contender (see below). Ambiguous provider outcomes become sticky `outcome-unknown` records when persistence remains available. A failed completion write leaves the prior snapshot available for reconciliation, never another provider write.
20
- - `audit-storage.js` owns the separate version-1 `source-control-audit.json` log under `OPENCHAMBER_DATA_DIR`. It does not share records, retention, or write sequencing with mutation replay storage. The strict schema stores one logical record per provider mutation idempotency key and per server Git operation ID. It accepts only `user` as the current initiator, the `provider-api` or `openchamber-server-git` executor, an immutable runtime and repository snapshot, an authoritative provider-user account ID or `null`, authoritative transport metadata, a compact redacted target, lifecycle timestamps, and a result state, stable error code, and bounded steps. An opaque credential ID is never substituted for provider identity. Provider API records require a `null` transport reference. Single-path Git records require either an exact opaque managed `credentialId` or a system marker. Sync records keep exact fetch and push references separately, including mixed system and managed transport. Checkout hydration records a bounded list of discovered auxiliary kinds and endpoint fingerprints plus only the exact server-validated transport reference for each granted endpoint; it records `null` transport when no transfer grant is used and always records a `null` provider account. Existing hydration records with the former single parent transport remain readable but new records never write that attribution. The schema rejects unknown fields and URL-shaped transport or target values, so raw endpoints, hydration paths, directories, key paths, credential payloads, process output, provider payloads, and free-form errors cannot enter the file. GitLab account IDs retain their authoritative normalized-instance prefix because that prefix is part of the account identifier, not a transport endpoint. Writes are serialized, atomic, and mode `0600`. Resolved terminal records expire after 30 days by default and are oldest-first capacity candidates; `planned`, `running`, and `outcome-unknown` records are never pruned or evicted.
20
+ - `audit-storage.js` owns the separate version-1 `source-control-audit.json` log under `OPENCHAMBER_DATA_DIR`. It does not share records, retention, or write sequencing with mutation replay storage. The strict schema stores one logical record per provider mutation idempotency key and per server Git operation ID. It accepts only `user` as the current initiator, the `provider-api` or `openchamber-server-git` executor, an immutable runtime and repository snapshot, an authoritative provider-user account ID or `null`, authoritative transport metadata, a compact redacted target, lifecycle timestamps, and a result state, stable error code, and bounded steps. An opaque credential ID is never substituted for provider identity. Provider API records require a `null` transport reference and a change-request target, or an issue target for `issue-comment`. Single-path Git records require either an exact opaque managed `credentialId` or a system marker. Sync records keep exact fetch and push references separately, including mixed system and managed transport. Checkout hydration records a bounded list of discovered auxiliary kinds and endpoint fingerprints plus only the exact server-validated transport reference for each granted endpoint; it records `null` transport when no transfer grant is used and always records a `null` provider account. Existing hydration records with the former single parent transport remain readable but new records never write that attribution. The schema rejects unknown fields and URL-shaped transport or target values, so raw endpoints, hydration paths, directories, key paths, credential payloads, process output, provider payloads, and free-form errors cannot enter the file. GitLab account IDs retain their authoritative normalized-instance prefix because that prefix is part of the account identifier, not a transport endpoint. Writes are serialized, atomic, and mode `0600`. Resolved terminal records expire after 30 days by default and are oldest-first capacity candidates; `planned`, `running`, and `outcome-unknown` records are never pruned or evicted.
21
21
  - `mutation-executor.js` also drives provider audit lifecycle around the existing durable claim. Exact in-process duplicates and durable replays use `provider:<idempotencyKey>` and therefore reuse the same audit record, while `transportReference` remains `null` because provider API calls do not use Git transport. The executor rejects an audited execution before claim when the route omits verified provider identity. Two credentials for the same provider user therefore share actor identity in receipts and audits, but their exact credential and binding authority still produce different mutation digests. Audit persistence does not replace or relax mutation claim, digest conflict, reconciliation, sticky unknown-outcome, or replay behavior.
22
22
 
23
23
  Provider replay and reconciliation after restart reuse the runtime snapshot from the existing audit record. Strict audit planning still checks the other immutable fields. Terminal replay does not rewrite audit history. An unfinished audit can complete from the durable mutation receipt without repeating provider work.
24
24
  - `binding-service.js` accepts remote names and account/credential references, normalizes provider instances before uniqueness checks and persistence, then resolves endpoint metadata and provider repository coordinates from current server-side Git state. A provider instance must match the selected remote host and applicable HTTP port; supplied repository coordinates must equal the path-derived coordinates. Client-provided URLs are never authoritative. Provider reads check the binding revision, the bound provider's own readiness and that its primary remote still exists, so a remote added or removed beside it does not interrupt change-request status. A repository with no binding (revision 0) or whose binding names no account for a host is read with the account the client is signed in to on that host, as before bindings existed: the context must name a remote that really points at the instance, and whether the account exists is the provider route's to decide. Mutation contexts pass the same checks plus the binding's saved config revision, then validate an idempotency key and an exact, copied target shape before provider code receives them. Stale revision or config/remote errors carry the current authoritative repository, revision, and redacted binding record. Account reconciliation uses the same normalization and also matches equivalent instance spellings in previously stored records.
25
25
  - `url-redaction.js` removes URL credentials, query data, and fragments from endpoint display URLs before repository context crosses the server boundary. Endpoint fingerprints remain derived from normalized transport authority rather than the shortened display, so hidden query or fragment changes still change authority.
26
26
  - `status-cache.js` keeps the last-known change request status per exact trusted read context (instance, account, credential revision, repository, binding revision, directory, branch, remote), bounded and TTL-fresh. A provider route answers a fresh repeat from it, answers a transient provider failure with the last-known status, drops a repository's entries after a mutation and an account's entries after invalidation. GitLab uses it; GitHub keeps its own equivalent in `github/routes.js` for now.
27
+ - `change-request-worktree.js` builds a change-request source from a directory and a pull or merge request number alone, for `session.create` with `pullRequest`. It chooses the read context the way the client's `getBoundSourceControlReadContexts` and `useGitHubReadContext` do: a ready bound provider first, else a remote on GitHub or a known GitLab instance read with that host's current account (each provider's `readCurrentAccountId`, the gh or glab login only while the user switched to it), GitHub before GitLab. The provider's `readChangeRequestHead` then answers the project, head branch, head revision and head owner on that context's primary remote; a GitHub pull request whose fork was deleted, or a number that is not one there, is refused with its own message. No caller-supplied project, endpoint or remote name enters the result, and the Git contributor pipeline resolves it again before any transfer.
27
28
  - `oauth-flow-registry.js` keeps device-flow secrets in a bounded, process-local registry. Clients receive an opaque flow ID; completion acquires the matching provider and normalized instance exclusively, then releases retryable attempts or consumes terminal attempts. Restarting the server invalidates every outstanding flow.
28
29
 
29
30
  ## Cross-process locks and recovery
@@ -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 { randomUUID } from 'node:crypto';
@@ -16,7 +17,11 @@ const GIT_OPERATIONS = new Set([
16
17
  ]);
17
18
  const PROVIDER_OPERATIONS = new Set([
18
19
  'change-request-create', 'change-request-update', 'change-request-merge', 'change-request-ready',
20
+ 'change-request-comment', 'change-request-review', 'issue-comment', 'change-request-state', 'issue-state',
21
+ 'change-request-labels', 'issue-labels', 'change-request-reviewers',
19
22
  ]);
23
+ // What a provider write acts on: `issue-*` names an issue, everything else a change request.
24
+ const providerTargetKind = (operation) => (operation.startsWith('issue-') ? 'issue' : 'change-request');
20
25
  const RESULT_STEPS = new Set([
21
26
  'validated', 'authenticated', 'transferred', 'updated-local-repository', 'checked-out', 'cleaned-up',
22
27
  'fetch:succeeded', 'fetch:skipped', 'fetch:conflicted', 'fetch:failed', 'fetch:cancelled',
@@ -26,10 +31,6 @@ const RESULT_STEPS = new Set([
26
31
  ]);
27
32
 
28
33
  const emptyState = () => ({ version: VERSION, records: {} });
29
- const isPlainObject = (value) => value === Object(value)
30
- && !Array.isArray(value)
31
- && Object.getPrototypeOf(value) === Object.prototype;
32
- const isString = (value) => Object.prototype.toString.call(value) === '[object String]';
33
34
  const hasExactKeys = (value, required, optional = []) => {
34
35
  const keys = Object.keys(value);
35
36
  return required.every((key) => keys.includes(key))
@@ -113,8 +114,8 @@ const isGitTarget = (value) => isPlainObject(value)
113
114
  && isSafeText(value.destination.displayName) && isSafeText(value.destination.fingerprint, 1024)));
114
115
  const isProviderTarget = (value) => isPlainObject(value)
115
116
  && hasExactKeys(value, ['kind', 'operation', 'projectId'], ['number', 'head', 'base', 'headSha'])
116
- && value.kind === 'change-request'
117
117
  && PROVIDER_OPERATIONS.has(value.operation)
118
+ && value.kind === providerTargetKind(value.operation)
118
119
  && isSafeText(value.projectId, 1024)
119
120
  && (value.number === undefined || isPositiveInteger(value.number))
120
121
  && (value.head === undefined || isSafeText(value.head, 1024))
@@ -150,7 +151,7 @@ const isRecord = (value, id) => {
150
151
  || !EXECUTORS.has(value.executorKind) || !isRuntime(value.runtime)
151
152
  || !isNullableText(value.repositoryId) || !isNullableAccountId(value.providerAccountId)
152
153
  || !isTransportReference(value.transportReference) || !isTarget(value.target)
153
- || (value.executorKind === 'provider-api' && (value.transportReference !== null || value.target.kind !== 'change-request'))
154
+ || (value.executorKind === 'provider-api' && (value.transportReference !== null || !isProviderTarget(value.target)))
154
155
  || (value.executorKind === 'openchamber-server-git' && (value.target.kind !== 'git-network'
155
156
  || !hydrationTransportMatchesTarget(value.transportReference, value.target)))
156
157
  || (value.target.kind === 'git-network' && value.target.operation === 'checkout-hydration'
@@ -53,6 +53,17 @@ describe('source-control audit storage', () => {
53
53
  expect(results.find((result) => !result.ok).error.code).toBe('SOURCE_CONTROL_AUDIT_CONFLICT');
54
54
  });
55
55
 
56
+ it('names an issue comment by its issue and refuses an issue target for other writes', async () => {
57
+ const { store } = await setup();
58
+ const issueTarget = { kind: 'issue', operation: 'issue-comment', projectId: 'project_one', number: 3 };
59
+ await expect(store.plan(plan('issue', { target: issueTarget }))).resolves.toBeTruthy();
60
+ await expect(store.plan(plan('review', { target: { kind: 'change-request', operation: 'change-request-review', projectId: 'project_one', number: 7 } })))
61
+ .resolves.toBeTruthy();
62
+ await expect(store.plan(plan('issue-state', { target: { ...issueTarget, operation: 'issue-state' } }))).resolves.toBeTruthy();
63
+ await expect(store.plan(plan('mismatch', { target: { ...issueTarget, operation: 'change-request-comment' } }))).rejects.toThrow();
64
+ await expect(store.plan(plan('mismatch-two', { target: { ...issueTarget, kind: 'change-request' } }))).rejects.toThrow();
65
+ });
66
+
56
67
  it('fails closed on busy reads and cleans failed snapshot writes', async () => {
57
68
  const { filePath, store } = await setup();
58
69
  await store.plan(plan('original'));
@@ -1,3 +1,4 @@
1
+ import { isPlainObject, isString } from '../shared/guards.js';
1
2
  import { normalizeSourceControlProviderInstance } from './provider-instance.js';
2
3
  import { resolvePrivateRepositoryIdentity } from './repository-identity.js';
3
4
  import { fingerprintRemoteUrl, redactRemoteUrl } from './url-redaction.js';
@@ -11,10 +12,6 @@ const bindingInputError = (message) => {
11
12
  return error;
12
13
  };
13
14
 
14
- const isPlainObject = (value) => value === Object(value)
15
- && !Array.isArray(value)
16
- && Object.getPrototypeOf(value) === Object.prototype;
17
- const isString = (value) => Object.prototype.toString.call(value) === '[object String]';
18
15
  const safePresentationText = (value, maximum = 512) => isString(value) && value.length > 0
19
16
  && value.length <= maximum && value.trim() === value && !/[\0\r\n]/.test(value);
20
17
  const safeProviderUsername = (value) => safePresentationText(value, 255) && /^[A-Za-z0-9_.-]+$/.test(value);
@@ -272,7 +269,7 @@ const normalizeProviderInstance = (provider, instance, makeError = bindingInputE
272
269
  };
273
270
 
274
271
  /** The host a remote address or a provider instance points at, lowercased; null when it names none. */
275
- const hostOf = (value) => {
272
+ export const hostOf = (value) => {
276
273
  const text = String(value ?? '').trim();
277
274
  if (!text) return null;
278
275
  try { return new URL(text.includes('://') ? text : `https://${text}`).hostname.toLowerCase() || null; }