@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,121 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { CallTemplate, Tool as UtcpTool } from '@utcp/sdk';
3
+ import type { CodeModeUtcpClient } from '@utcp/code-mode';
4
+ import type { ProxiedTool } from '@bevel-software/platform-mcp-core';
5
+
6
+ /**
7
+ * Tool definitions held once for every cached client that discovered the same
8
+ * ones.
9
+ *
10
+ * Two hundred people whose catalogs are identical would otherwise hold two
11
+ * hundred copies of the same descriptions and schemas: a client's repository
12
+ * keeps what discovery parsed, and every build parses its own. After a build,
13
+ * each manual's discovered tools are swapped for the first copy of identical
14
+ * tools this process has seen, so the descriptions, schemas and call templates
15
+ * are one object graph no matter how many clients point at it. A client keeps
16
+ * only what is its own: the repository's per-tool shells, its variables and
17
+ * its secrets loader.
18
+ *
19
+ * Identity is the CONTENT of the definitions, not the manual's name or its
20
+ * template: the knowledge base's manual is filtered per caller, so two people
21
+ * can get different tools from the same template, and only identical content
22
+ * may be shared. Definitions are tool schemas and call TEMPLATES — `${VAR}`
23
+ * placeholders, never a resolved value — so sharing them shares no secret.
24
+ *
25
+ * Nothing here is read back for correctness: a definition dropped from these
26
+ * maps (they are bounded) only stops the NEXT build from sharing it.
27
+ */
28
+ export class SharedDefinitions {
29
+ private readonly byContent = new Map<string, unknown>();
30
+ private readonly interfaceCaches = new Map<string, Map<string, string>>();
31
+
32
+ /** `maxEntries` bounds each map; 0 shares nothing. */
33
+ constructor(private readonly maxEntries = 1000) {}
34
+
35
+ /**
36
+ * The first-seen copy of `value` (by its JSON), or `value` itself when it is
37
+ * the first. Kinds share no key space: a tool list and a listing could
38
+ * otherwise serialise alike.
39
+ */
40
+ intern<T>(kind: string, value: T): T {
41
+ const key = `${kind}\u0000${fingerprint(value)}`;
42
+ const held = this.byContent.get(key);
43
+ if (held !== undefined) {
44
+ // Recently used goes last, so the first entry is the one to drop.
45
+ this.byContent.delete(key);
46
+ this.byContent.set(key, held);
47
+ return held as T;
48
+ }
49
+ if (this.maxEntries <= 0) return value;
50
+ bound(this.byContent, this.maxEntries);
51
+ this.byContent.set(key, value);
52
+ return value;
53
+ }
54
+
55
+ /**
56
+ * Point `client`'s repository at shared definitions: every manual's tools,
57
+ * and the client's cache of generated TypeScript interfaces.
58
+ *
59
+ * `rawTemplates` are the manuals as fetched, by the name they are registered
60
+ * under. Registration stores the template AFTER variable substitution — for
61
+ * a platform-hosted manual, with the bearer of the request that built the
62
+ * client in its headers. Nothing calls through a stored template (each
63
+ * tool's own template is substituted at call time), but a cached client
64
+ * must hold no request's credential, so the stored template is put back to
65
+ * its placeholders.
66
+ */
67
+ async share(client: CodeModeUtcpClient, rawTemplates: ReadonlyMap<string, CallTemplate>): Promise<void> {
68
+ const repo = client.config.tool_repository;
69
+ const templates = await repo.getManualCallTemplates();
70
+ const everyTool: UtcpTool[] = [];
71
+ for (const stored of templates) {
72
+ const name = String(stored.name ?? '');
73
+ const manual = await repo.getManual(name);
74
+ if (!manual) continue;
75
+ const tools = this.intern('tools', manual.tools);
76
+ everyTool.push(...tools);
77
+ const raw = rawTemplates.get(name);
78
+ const template = raw ? ({ ...raw, name } as CallTemplate) : stored;
79
+ await repo.saveManual(template, { ...manual, tools });
80
+ }
81
+ // `toolFunctionCache` is filled lazily by `tools_info` and every chain,
82
+ // keyed by tool name and derived from the definitions alone. Shared by
83
+ // every client holding the very same tool set, it is generated once for
84
+ // all of them instead of once per person.
85
+ (client as unknown as { toolFunctionCache: Map<string, string> }).toolFunctionCache = this.interfaceCache(
86
+ fingerprint(everyTool),
87
+ );
88
+ }
89
+
90
+ /** The flattened catalog a surface lists, shared the same way. */
91
+ listing(tools: ProxiedTool[]): ProxiedTool[] {
92
+ return this.intern('listing', tools);
93
+ }
94
+
95
+ /** The generated-interface cache of one exact tool set. */
96
+ private interfaceCache(toolSet: string): Map<string, string> {
97
+ let cache = this.interfaceCaches.get(toolSet);
98
+ if (cache) {
99
+ this.interfaceCaches.delete(toolSet);
100
+ } else {
101
+ cache = new Map();
102
+ if (this.maxEntries <= 0) return cache;
103
+ bound(this.interfaceCaches, this.maxEntries);
104
+ }
105
+ this.interfaceCaches.set(toolSet, cache);
106
+ return cache;
107
+ }
108
+ }
109
+
110
+ /** Make room for one more entry, dropping the least recently used. */
111
+ function bound(map: Map<string, unknown>, maxEntries: number): void {
112
+ while (map.size >= maxEntries) {
113
+ const oldest = map.keys().next().value;
114
+ if (oldest === undefined) break;
115
+ map.delete(oldest);
116
+ }
117
+ }
118
+
119
+ function fingerprint(value: unknown): string {
120
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
121
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The hosted MCP server's memory of built tool surfaces: one per caller and
3
+ * catalog revision, built once and reused until something it was built from
4
+ * changes.
5
+ *
6
+ * Keyed by whatever the service says identifies a surface — the person, the
7
+ * kind of caller, the key's scope and the catalog revision. A catalog change
8
+ * needs no listener: it is a new revision, so a new key, and the old entry
9
+ * ages out unused. What the revision cannot see (a secret) is evicted
10
+ * explicitly through {@link evictWhere} / {@link clear}.
11
+ *
12
+ * Bounded twice: an entry unused for `idleTtlMs` is dropped, and at most
13
+ * `maxEntries` are held, the least recently used going first. `maxEntries: 0`
14
+ * keeps nothing, which is the per-request build this replaced: the escape
15
+ * hatch if the cache ever has to be switched off.
16
+ *
17
+ * Single-flight: concurrent misses on one key share one build. A build that
18
+ * fails is not remembered, so the next request tries again. A build that was
19
+ * in flight when its key was evicted answers the requests waiting on it but is
20
+ * not kept: it was built from what the eviction said is stale. A build still
21
+ * running after `buildTimeoutMs` is let go the same way, so a discovery that
22
+ * hangs holds up the requests already waiting on it, not every later one.
23
+ *
24
+ * Holds references only. A surface owns no connection (its `mcp` manuals route
25
+ * to the downstream pool, which owns and closes those), so dropping an entry
26
+ * is the whole teardown.
27
+ */
28
+ export interface SurfaceCacheOptions {
29
+ /** Drop an entry unused for this long. Default one hour. */
30
+ idleTtlMs?: number;
31
+ /** Hold at most this many entries. Default 500; 0 keeps nothing. */
32
+ maxEntries?: number;
33
+ /** Let go of a build still running after this long; the next request starts another. Default two minutes. */
34
+ buildTimeoutMs?: number;
35
+ /** Clock, for tests. */
36
+ now?: () => number;
37
+ }
38
+
39
+ export const SURFACE_IDLE_TTL_MS = 60 * 60 * 1000;
40
+ export const SURFACE_BUILD_TIMEOUT_MS = 2 * 60 * 1000;
41
+ export const SURFACE_MAX_ENTRIES = 500;
42
+
43
+ interface Entry<V> {
44
+ value: Promise<V>;
45
+ startedAt: number;
46
+ lastUsed: number;
47
+ /** An absolute end, for a value that must not live its full idle time (see `maxAgeMs`). */
48
+ expiresAt: number;
49
+ /** False once the entry was evicted while its build was still running. */
50
+ live: boolean;
51
+ settled: boolean;
52
+ }
53
+
54
+ export class SurfaceCache<V> {
55
+ private readonly entries = new Map<string, Entry<V>>();
56
+ private readonly idleTtlMs: number;
57
+ private readonly maxEntries: number;
58
+ private readonly buildTimeoutMs: number;
59
+ private readonly now: () => number;
60
+
61
+ constructor(opts: SurfaceCacheOptions = {}) {
62
+ this.idleTtlMs = opts.idleTtlMs ?? SURFACE_IDLE_TTL_MS;
63
+ this.maxEntries = opts.maxEntries ?? SURFACE_MAX_ENTRIES;
64
+ this.buildTimeoutMs = opts.buildTimeoutMs ?? SURFACE_BUILD_TIMEOUT_MS;
65
+ this.now = opts.now ?? Date.now;
66
+ }
67
+
68
+ /**
69
+ * The value for `key`, built by `build` when none is held. `hit` says
70
+ * whether this call found one (or joined a build already running).
71
+ *
72
+ * `maxAgeMs`, read off a built value, ends that entry early whatever its
73
+ * use: a surface built while one of its manuals was failing is kept only
74
+ * as long as that failure would be remembered anyway.
75
+ */
76
+ async get(
77
+ key: string,
78
+ build: () => Promise<V>,
79
+ maxAgeMs?: (value: V) => number | undefined,
80
+ ): Promise<{ value: V; hit: boolean }> {
81
+ this.sweep();
82
+ const now = this.now();
83
+ const held = this.entries.get(key);
84
+ if (held) {
85
+ held.lastUsed = now;
86
+ // Most recently used goes last, so the first entry is always the one to drop.
87
+ this.entries.delete(key);
88
+ this.entries.set(key, held);
89
+ return { value: await held.value, hit: true };
90
+ }
91
+ if (this.maxEntries <= 0) return { value: await build(), hit: false };
92
+ while (this.entries.size >= this.maxEntries) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined) break;
95
+ this.drop(oldest);
96
+ }
97
+ const entry: Entry<V> = {
98
+ value: build(),
99
+ startedAt: now,
100
+ lastUsed: now,
101
+ expiresAt: Number.POSITIVE_INFINITY,
102
+ live: true,
103
+ settled: false,
104
+ };
105
+ this.entries.set(key, entry);
106
+ try {
107
+ const value = await entry.value;
108
+ entry.settled = true;
109
+ const maxAge = maxAgeMs?.(value);
110
+ if (maxAge !== undefined) entry.expiresAt = this.now() + maxAge;
111
+ if (!entry.live) this.forget(key, entry);
112
+ return { value, hit: false };
113
+ } catch (err) {
114
+ this.forget(key, entry);
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ /** Drop every entry whose key matches. A build in flight still answers its waiters, and is not kept. */
120
+ evictWhere(predicate: (key: string) => boolean): void {
121
+ for (const key of [...this.entries.keys()]) {
122
+ if (predicate(key)) this.drop(key);
123
+ }
124
+ }
125
+
126
+ /** Drop everything. */
127
+ clear(): void {
128
+ this.evictWhere(() => true);
129
+ }
130
+
131
+ private drop(key: string): void {
132
+ const entry = this.entries.get(key);
133
+ if (!entry) return;
134
+ entry.live = false;
135
+ this.entries.delete(key);
136
+ }
137
+
138
+ /** Remove `entry` only if it is still the one under `key` (a newer build may have taken its place). */
139
+ private forget(key: string, entry: Entry<V>): void {
140
+ if (this.entries.get(key) === entry) this.entries.delete(key);
141
+ }
142
+
143
+ /** Drop what has been idle too long, or outlived its own end, and let go of a build that has run too long. */
144
+ private sweep(): void {
145
+ const now = this.now();
146
+ for (const [key, entry] of this.entries) {
147
+ if (!entry.settled) {
148
+ if (now - entry.startedAt >= this.buildTimeoutMs) this.drop(key);
149
+ continue;
150
+ }
151
+ if (now - entry.lastUsed >= this.idleTtlMs || now >= entry.expiresAt) this.drop(key);
152
+ }
153
+ }
154
+ }
@@ -4,6 +4,7 @@ import express from 'express';
4
4
  import { describe, it, expect, afterEach, vi } from 'vitest';
5
5
 
6
6
  import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import { AccessControlService } from '../../access/access-control.service.js';
7
8
  import type { ISkillService, SkillSummary } from '../../skills/skills.contract.js';
8
9
  import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
9
10
  import type { IPluginIndexService, PluginCatalogEntry } from '../plugins.contract.js';
@@ -62,6 +63,10 @@ interface HarnessOpts {
62
63
  /** What the org-wide `everyone` principal reads. */
63
64
  everyone?: string[];
64
65
  email?: string | null;
66
+ /** Whether the caller is an admin, who is shown every group. Defaults to true. */
67
+ admin?: boolean;
68
+ /** The groups the caller is a member of, which is all a non-admin is shown. */
69
+ memberOf?: string[];
65
70
  }
66
71
 
67
72
  /** The org-wide entry every answer opens with — empty unless `everyone` says otherwise. */
@@ -82,6 +87,12 @@ async function harness(opts: HarnessOpts = {}) {
82
87
  opts.team && group in opts.team ? tableVerdict(opts.team[group], paths) : null,
83
88
  ),
84
89
  canReadAsEveryoneBatch: vi.fn(async (_w: string, paths: string[]) => tableVerdict(opts.everyone, paths)),
90
+ holdsAdminRootWrite: vi.fn(async () => opts.admin ?? true),
91
+ groupsOfUser: vi.fn(async () => opts.memberOf ?? []),
92
+ // The real policy over the two stubs above: who sees which group is the
93
+ // resolver's one answer, not something a route (or this double) decides.
94
+ visibleGroups: (wsId: string, email: string, groups: readonly string[]) =>
95
+ AccessControlService.prototype.visibleGroups.call(accessControl, wsId, email, groups),
85
96
  } as unknown as IAccessControl;
86
97
  const index = { catalog: async () => CATALOG, invalidate: () => undefined } as IPluginIndexService;
87
98
  const skills = { listSkills: async () => SKILLS } as unknown as ISkillService;
@@ -242,6 +253,50 @@ describe('GET /api/teams', () => {
242
253
  );
243
254
  });
244
255
 
256
+ it("shows someone who is not an admin only the groups they are in, never another group's name", async () => {
257
+ const h = await harness({
258
+ groups: ['Sales Team', 'Finance Team'],
259
+ caller: EVERYTHING,
260
+ team: {
261
+ 'Sales Team': ['Plugins/GTM', 'Skills/Sales/outreach/SKILL.md', 'Plugins/GTM/mcp.json'],
262
+ 'Finance Team': ['Plugins/Finance', 'Skills/Finance/ledger/SKILL.md', 'Plugins/Finance/books.tool'],
263
+ },
264
+ admin: false,
265
+ memberOf: ['Sales Team'],
266
+ });
267
+ const res = await h.get();
268
+ expect(res.status).toBe(200);
269
+ expect(await res.json()).toEqual({
270
+ teams: [
271
+ NOTHING_ORG_WIDE,
272
+ { name: 'Sales Team', plugins: ['gtm'], skills: ['outreach'], tools: ['hubspot'] },
273
+ ],
274
+ });
275
+ // A group that is not listed is not asked about either.
276
+ expect(h.accessControl.canReadAsGroupBatch).not.toHaveBeenCalledWith(
277
+ expect.anything(),
278
+ 'Finance Team',
279
+ expect.anything(),
280
+ );
281
+ });
282
+
283
+ it('shows someone in no group, who is not an admin, Everyone alone', async () => {
284
+ const h = await harness({ groups: ['Sales Team'], caller: EVERYTHING, team: { 'Sales Team': [] }, admin: false });
285
+ expect(await (await h.get()).json()).toEqual({ teams: [NOTHING_ORG_WIDE] });
286
+ });
287
+
288
+ it('shows an admin every group, including ones they are not in', async () => {
289
+ const h = await harness({
290
+ groups: ['Sales Team', 'Finance Team'],
291
+ caller: EVERYTHING,
292
+ team: { 'Sales Team': [], 'Finance Team': [] },
293
+ admin: true,
294
+ memberOf: [],
295
+ });
296
+ const body = (await (await h.get()).json()) as { teams: { name: string }[] };
297
+ expect(body.teams.map((t) => t.name)).toEqual(['Everyone', 'Sales Team', 'Finance Team']);
298
+ });
299
+
245
300
  it('is 401 without a caller', async () => {
246
301
  const h = await harness({ email: null });
247
302
  expect((await h.get()).status).toBe(401);
@@ -37,6 +37,10 @@ import type { IPluginIndexService, PluginCatalogEntry } from './plugins.contract
37
37
  * what `read: everyone` (or a public plugin) reaches. Same slice, same
38
38
  * caller gate, asked of the resolver as `canReadAsEveryoneBatch`. The name
39
39
  * cannot collide with a group's: `everyone` is reserved by the grammar.
40
+ *
41
+ * The groups listed after it are every group for an admin, and only the
42
+ * caller's own groups for anyone else (`groupsOfUser`), so the lens never
43
+ * names a group the caller has no part in.
40
44
  */
41
45
  export interface TeamAccess {
42
46
  name: string;
@@ -110,10 +114,19 @@ export function createTeamsRoutes(
110
114
  };
111
115
  };
112
116
 
117
+ // Which groups to list: the resolver's one answer to who sees which
118
+ // group (every group for an admin, only their own for anyone else), the
119
+ // same the share dialog offers and a grant may name. A group's NAME is
120
+ // information too: a deployment that gives each client, author or
121
+ // customer a group of their own would otherwise show every member the
122
+ // whole roster, even though the slices of groups they are not in come
123
+ // back empty.
124
+ const listed = await accessControl.visibleGroups(wsId, email, groups);
125
+
113
126
  const teams: TeamAccess[] = [
114
127
  slice(EVERYONE_TEAM, await accessControl.canReadAsEveryoneBatch(wsId, probes)),
115
128
  ];
116
- for (const name of groups) {
129
+ for (const name of listed) {
117
130
  const team = await accessControl.canReadAsGroupBatch(wsId, name, probes);
118
131
  if (team === null) continue; // gone between the two reads — nothing to say
119
132
  teams.push(slice(name, team));
@@ -10,13 +10,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
11
  import { createSetupRoutes } from '../setup.routes.js';
12
12
  import { DeploymentSettingsService } from '../deployment-settings.service.js';
13
- import { KbStartupRunner } from '../../workspace/startup/kb-startup-runner.js';
14
13
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
15
14
  import { TemplateFilesStep } from '../../workspace/startup/steps/template-files.step.js';
16
15
  import { NodeFs } from '../../kb-fs/node-fs.js';
17
16
  import { defaultKbTemplateDir } from '../../../assets.js';
18
17
  import type { Database } from '../../database/connection.js';
19
18
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
19
+ import { newKbStartupRunner } from '../../../__tests__/workspace-service.js';
20
20
 
21
21
  /**
22
22
  * The completing save and the REAL initialization phase, wired the way the
@@ -105,7 +105,7 @@ function boot() {
105
105
  // it: the completing save applies the admin's names to it, and the step
106
106
  // reads them from it when the phase runs.
107
107
  const kb = testKbContext();
108
- const runner = new KbStartupRunner({
108
+ const runner = newKbStartupRunner({
109
109
  gitRunner: new NodeGitRunner(),
110
110
  kbRepoUrl: () => upstream,
111
111
  workspacesRoot: path.join(root, 'workspaces'),
@@ -12,7 +12,6 @@ import { createSetupRoutes } from '../setup.routes.js';
12
12
  import { DeploymentSettingsService } from '../deployment-settings.service.js';
13
13
  import { ManagedRepository } from '../managed-repository.js';
14
14
  import { RepositorySource } from '../repository-source.js';
15
- import { KbStartupRunner } from '../../workspace/startup/kb-startup-runner.js';
16
15
  import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
17
16
  import { TemplateFilesStep } from '../../workspace/startup/steps/template-files.step.js';
18
17
  import { NodeFs } from '../../kb-fs/node-fs.js';
@@ -20,6 +19,7 @@ import { defaultKbTemplateDir } from '../../../assets.js';
20
19
  import type { Database } from '../../database/connection.js';
21
20
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
22
21
  import type { KbContext } from '../../../shared/kb-context.js';
22
+ import { newKbStartupRunner } from '../../../__tests__/workspace-service.js';
23
23
 
24
24
  /**
25
25
  * A repository the deployment keeps, and the REAL startup phase, wired the
@@ -89,7 +89,7 @@ function boot(kb: KbContext) {
89
89
  const gitRunner = new NodeGitRunner(60_000, source.credentials);
90
90
  const workspacesRoot = path.join(root, 'workspaces');
91
91
  const setAsideRoot = path.join(root, 'backups', 'replaced-working-copies');
92
- const runner = new KbStartupRunner({
92
+ const runner = newKbStartupRunner({
93
93
  gitRunner,
94
94
  kbRepoUrl: () => source.url(),
95
95
  workspacesRoot,
@@ -33,7 +33,7 @@ export interface InternalTokenClaim {
33
33
  * connection key to pass through). The verifier resolves such tokens to
34
34
  * `source: 'external'`: the caller IS an external agent, and must be treated
35
35
  * like one everywhere — admitted to external-only surfaces (`start_session`,
36
- * `ask`), refused from internal-only ones (`execute_command`, …), and
36
+ * `ask`), refused from internal-only ones (`switch_branch`, …), and
37
37
  * expected to carry `sessionId` on the tool body, not in the token.
38
38
  */
39
39
  externalProxy?: boolean;
@@ -66,8 +66,7 @@ const WWW_AUTH = 'Bearer realm="bevel-tools"';
66
66
  * Gate that admits ONLY in-process callers (those bearing an internal HMAC
67
67
  * token). Mount it AFTER `toolAuth` on every internal-only surface — the
68
68
  * internal tool catalog and the routes of tools registered with
69
- * `registerInternalTool` but NOT `registerExternalTool` (e.g. `execute_command`,
70
- * `switch_branch`, the `sharepoint_*` reads). `toolAuth` alone is not enough:
69
+ * `registerInternalTool` but NOT `registerExternalTool` (e.g. `switch_branch`, the `sharepoint_*` reads). `toolAuth` alone is not enough:
71
70
  * an external connection key authenticates successfully, so without this gate it
72
71
  * could invoke an internal-only route directly by guessing its name. The route
73
72
  * MUST stay mounted (our own agent reaches it over the same loopback HTTP), so
@@ -8,7 +8,6 @@ import express from 'express';
8
8
  import { afterEach, describe, expect, it, vi } from 'vitest';
9
9
  import { testKbContext, TEST_BRANCH_MODEL } from '../../../__tests__/kb-context.js';
10
10
  import { NodeFs } from '../../kb-fs/node-fs.js';
11
- import { WorkspaceService } from '../../workspace/workspace.service.js';
12
11
  import type { AuthService } from '../../auth/auth.service.js';
13
12
  import type { ICreatorAccess } from '../../access-model/creator.js';
14
13
  import type { WorkflowEventBus } from '../../workflow/event-bus.js';
@@ -24,6 +23,7 @@ import {
24
23
  type BranchDeclaration,
25
24
  type ToolDefSpec,
26
25
  } from '../index.js';
26
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
27
27
 
28
28
  const execFileAsync = promisify(execFile);
29
29
  const KB_DIR = 'knowledge-base';
@@ -116,7 +116,7 @@ describe('a tool declares how it treats its branch', () => {
116
116
  await runGit(seed, ['push', 'origin', 'HEAD:refs/heads/refs/heads/ghost']);
117
117
 
118
118
  kb = testKbContext({ kbDirName: KB_DIR });
119
- const workspaces = new WorkspaceService(workspacesRoot, upstream, kb, new NodeFs());
119
+ const workspaces = newWorkspaceService(workspacesRoot, upstream, kb, new NodeFs());
120
120
  const resolve = createToolContextResolver({
121
121
  authService: {
122
122
  getUserById: async () => ({ id: 'u', email: 'alice@example.com', name: 'Alice' }),
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { LocalFilesystem } from '@mastra/core/workspace';
4
4
  import type { IWorkflowService } from '@bevel-software/platform-shared';
5
5
  import { LockingFilesystem } from '../kb-fs/locking-filesystem.js';
6
+ import type { WriteValidator } from '../kb-fs/index.js';
6
7
  import { ReadOnlyFilesystem } from '../kb-fs/read-only-filesystem.js';
7
8
  import {
8
9
  makeAgentRolesYamlWriteValidator,
@@ -76,6 +77,36 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
76
77
  if (!user) throw new ToolError('Your account is no longer available.', 401);
77
78
 
78
79
  const { loadActiveGroups } = deps;
80
+ const validatorCache = new Map<string, WriteValidator>();
81
+ /**
82
+ * The agent's pre-disk `roles.yaml` gate for `branch`'s workspace
83
+ * (cached): refuses a write that would leave the file unparseable (an
84
+ * app-wide admin lockout), that creates a role, or that adds a
85
+ * `- group:` entry naming no group — agents manage membership; roles are
86
+ * pre-set. ONE gate for every agent write to the workspace: the file
87
+ * tools take it before a byte lands, and `update_branch` takes it for a
88
+ * resolution's content before the merge.
89
+ */
90
+ const getWriteValidator = async (branch: string): Promise<WriteValidator> => {
91
+ assertBranchProvided(branch);
92
+ const cached = validatorCache.get(branch);
93
+ if (cached) return cached;
94
+ const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
95
+ const basePath = await deps.workspaceService.getWorkspacePath(ws.id);
96
+ const validator = makeAgentRolesYamlWriteValidator(
97
+ deps.kbDirName,
98
+ () => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
99
+ loadActiveGroups &&
100
+ (async () => {
101
+ // A source that exists but cannot be read leaves group entries
102
+ // unchecked rather than refusing every one.
103
+ const active = await loadActiveGroups((file) => readTextIfExists(path.join(basePath, deps.kbDirName, file)));
104
+ return active.health.ok ? active : null;
105
+ }),
106
+ );
107
+ validatorCache.set(branch, validator);
108
+ return validator;
109
+ };
79
110
  const fsCache = new Map<string, LocalFilesystem>();
80
111
  const getFilesystem = async (branch: string): Promise<LocalFilesystem> => {
81
112
  // THE choke point: every knowledge-base tool resolves its workspace
@@ -94,6 +125,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
94
125
  assertBranchProvided(branch);
95
126
  const cached = fsCache.get(branch);
96
127
  if (cached) return cached;
128
+ // (The write validator below is built through `getWriteValidator`.)
97
129
  const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
98
130
  const workspaceId = ws.id;
99
131
  const basePath = await deps.workspaceService.getWorkspacePath(workspaceId);
@@ -107,24 +139,10 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
107
139
  branch: branchForWorkspaceId(workspaceId),
108
140
  user,
109
141
  kbDirName: deps.kbDirName,
110
- // Reject an agent write that would leave roles.yaml unparseable
111
- // (app-wide admin lockout) or that creates a role before it
112
- // reaches disk, or that adds a `- group:` entry naming no group
113
- // — the agent gets a tool error and the file is untouched.
114
- // Agents manage membership; roles are pre-set.
115
- validateWrite: makeAgentRolesYamlWriteValidator(
116
- deps.kbDirName,
117
- () => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
118
- loadActiveGroups &&
119
- (async () => {
120
- // A source that exists but cannot be read leaves group
121
- // entries unchecked rather than refusing every one.
122
- const active = await loadActiveGroups((file) =>
123
- readTextIfExists(path.join(basePath, deps.kbDirName, file)),
124
- );
125
- return active.health.ok ? active : null;
126
- }),
127
- ),
142
+ // The agent's pre-disk roles.yaml gate (`getWriteValidator`):
143
+ // the file tools and `update_branch`'s resolutions take the
144
+ // same one.
145
+ validateWrite: await getWriteValidator(branch),
128
146
  creatorAccess: deps.creatorAccess,
129
147
  },
130
148
  )
@@ -155,6 +173,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
155
173
  workflowService: deps.workflowService,
156
174
  events: deps.events,
157
175
  getFilesystem,
176
+ getWriteValidator,
158
177
  };
159
178
  };
160
179
  }
@@ -36,8 +36,8 @@ export interface ToolHandlerOptions {
36
36
  writeScope?: boolean;
37
37
  /**
38
38
  * How this route treats `branch`, when it must differ from what its
39
- * `toolDef` recorded. Only `execute_command` sets it (`own`): it falls back
40
- * to an internal caller's focused branch rather than being refused.
39
+ * `toolDef` recorded (`own`: the handler resolves it itself rather than
40
+ * being refused).
41
41
  */
42
42
  branch?: BranchHandling;
43
43
  }
@@ -2,6 +2,7 @@ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared
2
2
  import type { LocalFilesystem } from '@mastra/core/workspace';
3
3
  import type { WorkspaceService } from '../workspace/workspace.service.js';
4
4
  import type { WorkflowEventBus } from '../workflow/event-bus.js';
5
+ import type { WriteValidator } from '../kb-fs/index.js';
5
6
 
6
7
  /**
7
8
  * The hosting/execution contract a module works against when it lets `toolHelpers`
@@ -41,8 +42,8 @@ export interface ToolContext {
41
42
  sessionId?: string;
42
43
  /**
43
44
  * The branch the in-process agent's workspace is focused on, from its internal
44
- * token. An internal-only workspace tool (e.g. `execute_command`) falls back to
45
- * it when the model omits `branch`, so the in-app chat agent acts on its own
45
+ * token. A tool may fall back to it when the model omits `branch` (`list_tool_setup`
46
+ * reads the focused branch's setup), so the in-app chat agent acts on its own
46
47
  * workspace even on a branch-less call. Only set for `source: 'internal'`;
47
48
  * external callers must name the branch explicitly (a present-but-invalid value
48
49
  * still fails closed regardless of this).
@@ -63,6 +64,13 @@ export interface ToolContext {
63
64
  events: WorkflowEventBus;
64
65
  /** Build the lock-aware filesystem for `branch`'s workspace (cached). The tool supplies `branch` from its own input. */
65
66
  getFilesystem(branch: string): Promise<LocalFilesystem>;
67
+ /**
68
+ * The agent's pre-disk write gate for `branch`'s workspace (cached) — the
69
+ * one the filesystem above runs before a byte lands (`roles.yaml`: must
70
+ * parse, no new role, no unknown group). A tool that writes content by
71
+ * another road than that filesystem (a merge resolution) takes it too.
72
+ */
73
+ getWriteValidator(branch: string): Promise<WriteValidator>;
66
74
  }
67
75
 
68
76
  /**
@@ -59,7 +59,7 @@ const CORE_SERVER_SOURCE = new URL('../../../core/create-core-server.ts', import
59
59
  const MODULES: ReadonlyArray<{ name: string; register: (registry: ToolRegistry) => void }> = [
60
60
  {
61
61
  name: 'registerWorkflowTools',
62
- register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true),
62
+ register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never),
63
63
  },
64
64
  {
65
65
  name: 'registerChangeRequestReadTools',
@@ -15,6 +15,7 @@ import type { UtcpTool } from '../tool.contract.js';
15
15
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
16
16
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
17
17
  import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
18
+ import { WorkerPatternMatcher } from '../../workspace/bounded-matcher.js';
18
19
  import { registerWorkflowTools } from '../../workflow/agent-tools/workflow.tools.js';
19
20
  import { registerPluginsTools } from '../../plugins/plugins.tools.js';
20
21
  import { registerSkillsTools } from '../../skills/skills.tools.js';
@@ -110,8 +111,13 @@ async function hexisTools(skills: Parameters<typeof registerSkillsTools>[4] = em
110
111
  // mounted on the same terms as the upload one.
111
112
  undefined,
112
113
  unused(),
114
+ // Starter packs and the tree walk, not needed; then the grep matcher,
115
+ // mounted on the same terms (it spawns no worker until a search runs).
116
+ undefined,
117
+ undefined,
118
+ new WorkerPatternMatcher(),
113
119
  );
114
- registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true);
120
+ registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never);
115
121
  registerPluginsTools(registry, kb);
116
122
  registerSkillsTools(registry, router, toolAuth, toolHandler, skills);
117
123
  registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
@@ -257,7 +263,6 @@ describe('every file tool opens with the guide-first sentence and carries no sha
257
263
  'move_file',
258
264
  'copy_file',
259
265
  'unzip',
260
- 'execute_command',
261
266
  'apply_file_upload',
262
267
  ];
263
268