@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isAbsence } from '../../shared/fs.contract.js';
4
+ import { gitDirOf } from './git-dir.js';
5
+
6
+ /**
7
+ * Where a ref points, read from the repository's own files instead of a
8
+ * `git rev-parse` process.
9
+ *
10
+ * A branch tip is a small file git maintains: `refs/remotes/origin/<branch>`
11
+ * as a loose file, or one line of `packed-refs`. Reading it costs a file read
12
+ * and no fork, and it is exactly as current as the last fetch — the same
13
+ * freshness a `rev-parse` has. A change-request listing resolves two tips per
14
+ * open request and the access gates resolve every ref they decide at; with a
15
+ * hundred open requests that was hundreds of forks per listing, each costing
16
+ * tens of milliseconds of the main thread on a server holding a large heap.
17
+ *
18
+ * What is read, in git's own order: a loose file under the common directory
19
+ * (a worktree shares `refs/` with the repository it belongs to), then
20
+ * `packed-refs`. `HEAD` and the per-worktree namespaces (`refs/worktree/`,
21
+ * `refs/bisect/`, `refs/rewritten/`) are a worktree's own: read loose from
22
+ * its git directory, never from the common directory, and never packed. A
23
+ * symbolic ref (`ref: refs/heads/main`) is followed. A tag is never decided
24
+ * here: its file may hold a tag object, and what that peels to is git's to
25
+ * say.
26
+ *
27
+ * The answer is one of three things, and the caller treats them differently:
28
+ * `{ sha }` is what git would answer too; `'missing'` means every file git
29
+ * would consult says there is no such ref, which is as authoritative as
30
+ * `rev-parse` answering nothing; `'unreadable'` means this reader could not
31
+ * decide — a repository it does not understand (one whose refs live in a
32
+ * reftable, say), a tag, a corrupt file, a name it refuses — and the caller
33
+ * asks git, which is authoritative. Never a guess: a read error is
34
+ * `'unreadable'`, not `'missing'`, so a flaky disk sends the caller to git
35
+ * rather than making a branch disappear.
36
+ *
37
+ * What the files guarantee about the object: git itself refuses to write a
38
+ * non-commit under `refs/heads/`, and `refs/remotes/origin/` mirrors the
39
+ * remote's `refs/heads/`, so a branch tip read here is a commit as surely
40
+ * as `rev-parse <branch>^{commit}` would make it one. A caller that holds
41
+ * an object id rather than a name, or a `<rev>^{...}` form, asks git.
42
+ */
43
+ export type RefFileAnswer = { sha: string } | 'missing' | 'unreadable';
44
+
45
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
46
+ const MAX_SYMREF_DEPTH = 5;
47
+ const PER_WORKTREE = /^refs\/(?:worktree|bisect|rewritten)\//;
48
+
49
+ /** A ref name this reader is willing to turn into a path under `.git`. */
50
+ function isSafeRefName(ref: string): boolean {
51
+ if (ref === 'HEAD') return true;
52
+ if (!ref.startsWith('refs/')) return false;
53
+ if (!/^[A-Za-z0-9._\-/@+]+$/.test(ref)) return false;
54
+ return !ref.split('/').some((segment) => segment === '' || segment === '.' || segment === '..');
55
+ }
56
+
57
+ /**
58
+ * One resolution's view of the repository: the two directories, and the
59
+ * `packed-refs` table read at most once however many candidates a name
60
+ * tries — the table can grow with the history, and a name that is missing
61
+ * as itself consults it for every prefix.
62
+ */
63
+ interface GitDirs {
64
+ /** The worktree's own git directory: `HEAD` lives here. */
65
+ gitDir: string;
66
+ /** Where `refs/` and `packed-refs` live: the repository, shared by every worktree. */
67
+ commonDir: string;
68
+ /** The `packed-refs` table, read on first need; null when it could not be read. */
69
+ packed?: Promise<Map<string, string> | null>;
70
+ }
71
+
72
+ async function gitDirsOf(repoDir: string): Promise<GitDirs | null> {
73
+ let gitDir: string | null;
74
+ try {
75
+ gitDir = await gitDirOf(repoDir);
76
+ } catch {
77
+ return null;
78
+ }
79
+ if (gitDir === null) return null;
80
+ let commonDir = gitDir;
81
+ try {
82
+ const pointer = (await fs.readFile(path.join(gitDir, 'commondir'), 'utf-8')).trim();
83
+ if (pointer) commonDir = path.resolve(gitDir, pointer);
84
+ } catch (err) {
85
+ if (!isAbsence(err)) return null;
86
+ }
87
+ // A repository whose refs live in a reftable (`extensions.refStorage`)
88
+ // keeps nothing this reader understands under `refs/` or in `packed-refs`:
89
+ // every name would read as missing. Not a repository for this reader.
90
+ try {
91
+ await fs.stat(path.join(commonDir, 'reftable'));
92
+ return null;
93
+ } catch (err) {
94
+ if (!isAbsence(err)) return null;
95
+ }
96
+ try {
97
+ if (/^\s*refstorage\s*=\s*reftable\s*$/im.test(await fs.readFile(path.join(commonDir, 'config'), 'utf-8'))) {
98
+ return null;
99
+ }
100
+ } catch (err) {
101
+ if (!isAbsence(err)) return null;
102
+ }
103
+ return { gitDir, commonDir };
104
+ }
105
+
106
+ /**
107
+ * `packed-refs`: `<sha> <name>` lines, `^<sha>` peel lines, `#` comments.
108
+ * Absent file → an empty table. A line that is none of those is a file this
109
+ * reader does not understand, and the whole table is null (unreadable): a
110
+ * record it skipped could be the one that was asked for.
111
+ */
112
+ async function readPackedRefs(commonDir: string): Promise<Map<string, string> | null> {
113
+ let text: string;
114
+ try {
115
+ text = await fs.readFile(path.join(commonDir, 'packed-refs'), 'utf-8');
116
+ } catch (err) {
117
+ return isAbsence(err) ? new Map() : null;
118
+ }
119
+ const table = new Map<string, string>();
120
+ for (const raw of text.split('\n')) {
121
+ const line = raw.trim();
122
+ if (!line || line.startsWith('#')) continue;
123
+ if (line.startsWith('^')) {
124
+ if (!SHA.test(line.slice(1))) return null;
125
+ continue;
126
+ }
127
+ const space = line.indexOf(' ');
128
+ const sha = space < 0 ? '' : line.slice(0, space);
129
+ const name = space < 0 ? '' : line.slice(space + 1).trim();
130
+ if (!SHA.test(sha) || !name) return null;
131
+ table.set(name, sha);
132
+ }
133
+ return table;
134
+ }
135
+
136
+ /**
137
+ * The file at `<dir>/<relative>`, read as git reads a loose ref: a symbolic
138
+ * ref is followed, an object id is the answer, anything else is not decided.
139
+ */
140
+ async function readLooseRef(dirs: GitDirs, dir: string, relative: string, depth: number): Promise<RefFileAnswer> {
141
+ let text: string;
142
+ try {
143
+ text = (await fs.readFile(path.join(dir, ...relative.split('/')), 'utf-8')).trim();
144
+ } catch (err) {
145
+ return isAbsence(err) ? 'missing' : 'unreadable';
146
+ }
147
+ if (text.startsWith('ref:')) {
148
+ const answer = await resolveIn(dirs, text.slice('ref:'.length).trim(), depth + 1);
149
+ // A symbolic ref whose target is gone is dangling, and git reports it
150
+ // as such; a packed entry under the symbolic ref's own name does not
151
+ // stand in for it. Git's to report, then.
152
+ return answer === 'missing' ? 'unreadable' : answer;
153
+ }
154
+ return SHA.test(text) ? { sha: text } : 'unreadable';
155
+ }
156
+
157
+ async function resolveIn(dirs: GitDirs, ref: string, depth: number): Promise<RefFileAnswer> {
158
+ if (depth > MAX_SYMREF_DEPTH) return 'unreadable';
159
+ if (!isSafeRefName(ref)) return 'unreadable';
160
+ // `HEAD` and the per-worktree namespaces (`refs/worktree/`, `refs/bisect/`,
161
+ // `refs/rewritten/`) are a worktree's own, kept loose in its git directory
162
+ // and never packed; everything else is the repository's.
163
+ const own = ref === 'HEAD' || PER_WORKTREE.test(ref);
164
+ const loose = await readLooseRef(dirs, own ? dirs.gitDir : dirs.commonDir, ref, depth);
165
+ if (loose !== 'missing') return decidedHere(ref, loose);
166
+ if (own) return 'missing';
167
+ const packed = await (dirs.packed ??= readPackedRefs(dirs.commonDir));
168
+ if (!packed) return 'unreadable';
169
+ const sha = packed.get(ref);
170
+ return sha ? decidedHere(ref, { sha }) : 'missing';
171
+ }
172
+
173
+ /**
174
+ * The files decide only where git itself guarantees the object is a commit:
175
+ * `HEAD` and `refs/heads/*`, which git refuses to point at anything else,
176
+ * and `refs/remotes/*`, fetched from a remote's `refs/heads/*` under the
177
+ * same refusal. Anything else that exists — a tag, a ref directly under
178
+ * `refs/` — may name a tree or a blob, which only git's `^{commit}` refuses,
179
+ * so it is git's to resolve. Anything that does not exist is as missing as
180
+ * any other ref, so a short name can move on to the branch git would find
181
+ * next.
182
+ */
183
+ function decidedHere(ref: string, answer: RefFileAnswer): RefFileAnswer {
184
+ if (answer !== 'missing' && answer !== 'unreadable' && !commitGuaranteed(ref)) return 'unreadable';
185
+ return answer;
186
+ }
187
+
188
+ function commitGuaranteed(ref: string): boolean {
189
+ return ref === 'HEAD' || ref.startsWith('refs/heads/') || ref.startsWith('refs/remotes/');
190
+ }
191
+
192
+ /**
193
+ * The commit a full ref name (`refs/...`) or `HEAD` points at in the
194
+ * repository behind `repoDir` (a clone, or a worktree of one). See the module
195
+ * doc for the three answers and what each means to the caller.
196
+ *
197
+ * "Missing" is what `rev-parse` answers nothing for, and `rev-parse` tries
198
+ * the same prefixes for a full name as for a short one (`refs/heads/x` is
199
+ * also looked for as `refs/heads/refs/heads/x`), so a name that is missing
200
+ * as itself but present under a prefix is git's to resolve.
201
+ */
202
+ export async function readRefSha(repoDir: string, ref: string): Promise<RefFileAnswer> {
203
+ const dirs = await gitDirsOf(repoDir);
204
+ if (!dirs) return 'unreadable';
205
+ const exact = await resolveIn(dirs, ref, 0);
206
+ if (exact !== 'missing') return exact;
207
+ for (const name of prefixedCandidates(ref)) {
208
+ if ((await resolveIn(dirs, name, 0)) !== 'missing') return 'unreadable';
209
+ }
210
+ return 'missing';
211
+ }
212
+
213
+ /**
214
+ * The names `git rev-parse` tries for `<name>` after `$GIT_DIR/<name>`
215
+ * itself, in git's order (gitrevisions(7)): `refs/<name>`,
216
+ * `refs/tags/<name>`, `refs/heads/<name>`, `refs/remotes/<name>`,
217
+ * `refs/remotes/<name>/HEAD`. The same for every spelling, a full `refs/...`
218
+ * name included.
219
+ */
220
+ function prefixedCandidates(name: string): string[] {
221
+ return [`refs/${name}`, `refs/tags/${name}`, `refs/heads/${name}`, `refs/remotes/${name}`, `refs/remotes/${name}/HEAD`];
222
+ }
223
+
224
+ /**
225
+ * Every name `git rev-parse` tries for a spelling, in git's order: the
226
+ * spelling itself at `$GIT_DIR/<name>` (which for `HEAD` and a full
227
+ * `refs/...` name is the ref file), then the prefixed forms. Null for a
228
+ * spelling this reader does not resolve — an object id or its abbreviation,
229
+ * a `<rev>^{...}` or `@{...}` form, an empty or option-looking name — which
230
+ * the caller sends to git.
231
+ */
232
+ function refNameCandidates(ref: string): string[] | null {
233
+ if (!ref || ref.startsWith('-')) return null;
234
+ if (/[\^~:@{}\\\s]/.test(ref)) return null;
235
+ if (/^[0-9a-f]{4,64}$/i.test(ref)) return null;
236
+ // `main-worktree/<ref>` and `worktrees/<name>/<ref>` reach another
237
+ // worktree's own refs (gitrevisions(7)): git's to resolve.
238
+ if (/^(?:main-worktree|worktrees)\//.test(ref)) return null;
239
+ return [ref, ...prefixedCandidates(ref)];
240
+ }
241
+
242
+ /**
243
+ * {@link readRefSha} for a name as a caller holds it (`main`, `origin/main`,
244
+ * `HEAD`, `refs/heads/main`): the first of git's candidates that resolves,
245
+ * starting where git starts, at `$GIT_DIR/<name>` itself. Whatever is found
246
+ * there (`MERGE_HEAD`, `FETCH_HEAD`, `AUTO_MERGE`, which may name a tree) is
247
+ * git's to resolve; only its absence lets the `refs/` candidates be read.
248
+ * `'missing'` only when every candidate is missing; `'unreadable'` as soon
249
+ * as one cannot be decided, because the undecided one might be the one git
250
+ * would pick.
251
+ */
252
+ export async function readRevSha(repoDir: string, rev: string): Promise<RefFileAnswer> {
253
+ const candidates = refNameCandidates(rev);
254
+ if (!candidates) return 'unreadable';
255
+ const dirs = await gitDirsOf(repoDir);
256
+ if (!dirs) return 'unreadable';
257
+ for (const name of candidates) {
258
+ const answer = name === rev ? await resolveAsItself(dirs, rev) : await resolveIn(dirs, name, 0);
259
+ if (answer !== 'missing') return answer;
260
+ }
261
+ return 'missing';
262
+ }
263
+
264
+ /**
265
+ * Git's first step for any spelling: `$GIT_DIR/<name>`. For `HEAD` and a
266
+ * full `refs/...` name that is the ref itself, read as such. For anything
267
+ * else it is a pseudoref (`MERGE_HEAD`, `AUTO_MERGE`, which may name a
268
+ * tree), and whatever exists there is git's to resolve.
269
+ */
270
+ async function resolveAsItself(dirs: GitDirs, rev: string): Promise<RefFileAnswer> {
271
+ if (rev === 'HEAD' || rev.startsWith('refs/')) return resolveIn(dirs, rev, 0);
272
+ if (!isSafeRefName(`refs/${rev}`)) return 'unreadable';
273
+ try {
274
+ await fs.stat(path.join(dirs.gitDir, ...rev.split('/')));
275
+ return 'unreadable';
276
+ } catch (err) {
277
+ return isAbsence(err) ? 'missing' : 'unreadable';
278
+ }
279
+ }
@@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http';
2
2
  import type { RequestHandler } from 'express';
3
3
  import express from 'express';
4
4
  import { createMcpRoutes } from '../mcp.routes.js';
5
+ import type { McpCallerKind } from '../mcp-auth.middleware.js';
5
6
 
6
7
  /**
7
8
  * The shared mount for every `createMcpRoutes` suite.
@@ -19,7 +20,8 @@ import { createMcpRoutes } from '../mcp.routes.js';
19
20
  */
20
21
 
21
22
  /**
22
- * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A.
23
+ * Auth stand-in binding the user named by `x-test-user`, defaulting to user-A,
24
+ * and the credential kind named by `x-test-kind`, when one is.
23
25
  *
24
26
  * The header lets a suite act as a second user (the cross-user checks need
25
27
  * one); suites that never send it see the single fixed user their assertions
@@ -27,6 +29,10 @@ import { createMcpRoutes } from '../mcp.routes.js';
27
29
  */
28
30
  export const fakeAuth: RequestHandler = (req, _res, next) => {
29
31
  req.userId = (req.headers['x-test-user'] as string | undefined) ?? 'user-A';
32
+ // The credential kind the real middleware would have recognised, when a
33
+ // suite needs one (the page header is a session's alone).
34
+ const kind = req.headers['x-test-kind'] as McpCallerKind | undefined;
35
+ if (kind) req.mcpCallerKind = kind;
30
36
  next();
31
37
  };
32
38
 
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import type { McpCaller } from '../mcp.service.js';
5
+ import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
6
+
7
+ /**
8
+ * The page a call comes from: an HTML page of the knowledge base whose script
9
+ * calls the viewer's tools through the app. The app names it in
10
+ * `X-Bevel-Page`, and only the app — a session — may: a key, an OAuth token or
11
+ * an internal token belongs to an agent with an Audit log row of its own.
12
+ */
13
+
14
+ afterEach(async () => {
15
+ await closeMountedRoutes();
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ function makeMcpService() {
20
+ const callers: McpCaller[] = [];
21
+ return {
22
+ callers,
23
+ createRequestServer: vi.fn(async (caller: McpCaller) => {
24
+ callers.push(caller);
25
+ const server = new Server({ name: 'stub', version: '0' }, { capabilities: { tools: {} } });
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
27
+ return server;
28
+ }),
29
+ recordPageRefusal: vi.fn(async () => {}),
30
+ };
31
+ }
32
+
33
+ const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
34
+ const PAGE = 'Dashboards/Road map — Q4.html';
35
+
36
+ function post(baseUrl: string, path: string, body: unknown, headers: Record<string, string>) {
37
+ return fetch(`${baseUrl}/api${path}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
40
+ body: JSON.stringify(body),
41
+ });
42
+ }
43
+
44
+ describe('POST /mcp with X-Bevel-Page', () => {
45
+ it("hands a session's page to the service, decoded", async () => {
46
+ const mcpService = makeMcpService();
47
+ const baseUrl = await mountMcpRoutes({ mcpService });
48
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent(PAGE) });
49
+ expect(res.status).toBe(200);
50
+ expect(mcpService.callers[0]).toMatchObject({ userId: 'user-A', page: PAGE });
51
+ });
52
+
53
+ it.each(['key', 'oauth', 'internal'])('refuses the header from a %s caller, building nothing', async (kind) => {
54
+ const mcpService = makeMcpService();
55
+ const baseUrl = await mountMcpRoutes({ mcpService });
56
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': kind, 'x-bevel-page': encodeURIComponent(PAGE) });
57
+ expect(res.status).toBe(400);
58
+ expect(((await res.json()) as { error: { message: string } }).error.message).toContain('X-Bevel-Page');
59
+ expect(mcpService.createRequestServer).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('refuses a page that is not a path', async () => {
63
+ const mcpService = makeMcpService();
64
+ const baseUrl = await mountMcpRoutes({ mcpService });
65
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent('a\nb') });
66
+ expect(res.status).toBe(400);
67
+ });
68
+
69
+ it('names no page when the header is absent, as before', async () => {
70
+ const mcpService = makeMcpService();
71
+ const baseUrl = await mountMcpRoutes({ mcpService });
72
+ await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session' });
73
+ expect(mcpService.callers[0]).not.toHaveProperty('page');
74
+ });
75
+ });
76
+
77
+ describe('POST /mcp/page-refusals', () => {
78
+ it("records a session's refused call under its page", async () => {
79
+ const mcpService = makeMcpService();
80
+ const baseUrl = await mountMcpRoutes({ mcpService });
81
+ const res = await post(
82
+ baseUrl,
83
+ '/mcp/page-refusals',
84
+ { page: PAGE, tool: 'edit_file', args: { body: { path: 'a.md' } } },
85
+ { 'x-test-kind': 'session', Authorization: 'Bearer eyJ.session' },
86
+ );
87
+ expect(res.status).toBe(204);
88
+ expect(mcpService.recordPageRefusal).toHaveBeenCalledWith(
89
+ { userId: 'user-A', bearer: 'eyJ.session' },
90
+ PAGE,
91
+ 'edit_file',
92
+ { body: { path: 'a.md' } },
93
+ );
94
+ });
95
+
96
+ it('is the app\'s alone: an agent credential is refused', async () => {
97
+ const mcpService = makeMcpService();
98
+ const baseUrl = await mountMcpRoutes({ mcpService });
99
+ const res = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool: 'edit_file' }, { 'x-test-kind': 'key' });
100
+ expect(res.status).toBe(403);
101
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('wants a page and a tool', async () => {
105
+ const mcpService = makeMcpService();
106
+ const baseUrl = await mountMcpRoutes({ mcpService });
107
+ const res = await post(baseUrl, '/mcp/page-refusals', { tool: 'edit_file' }, { 'x-test-kind': 'session' });
108
+ expect(res.status).toBe(400);
109
+ for (const tool of [undefined, '']) {
110
+ const noTool = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool }, { 'x-test-kind': 'session' });
111
+ expect(noTool.status).toBe(400);
112
+ }
113
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
114
+ });
115
+ });