@bevel-software/platform-core-backend 0.4.1 → 0.5.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 (304) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +144 -52
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +381 -108
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  258. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  259. package/src/modules/workflow/event-bus.ts +25 -0
  260. package/src/modules/workflow/file-lock.service.ts +17 -0
  261. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  262. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  263. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  264. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  265. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  266. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  267. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  268. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  269. package/src/modules/workflow/git/branch-name.ts +5 -1
  270. package/src/modules/workflow/git/git.service.ts +159 -62
  271. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  272. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  273. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  274. package/src/modules/workflow/workflow.routes.ts +91 -13
  275. package/src/modules/workflow/workflow.service.ts +353 -13
  276. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  277. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  278. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  279. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  280. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  281. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  282. package/src/modules/workspace/bevel-ignore.ts +66 -62
  283. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  284. package/src/modules/workspace/kb-seed.service.ts +247 -53
  285. package/src/modules/workspace/workspace.routes.ts +54 -18
  286. package/src/modules/workspace/workspace.service.ts +45 -7
  287. package/src/modules/workspace/workspace.tools.ts +922 -917
  288. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  289. package/src/shared/__tests__/join-request.test.ts +96 -0
  290. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  291. package/src/shared/kb-layout.test.ts +2 -2
  292. package/src/test-setup.ts +18 -0
  293. package/kb-template/Agents/README.md +0 -38
  294. package/kb-template/CLAUDE.md +0 -419
  295. package/kb-template/Data/.gitkeep +0 -0
  296. package/kb-template/Data/README.md +0 -39
  297. package/kb-template/KnowledgeBase/README.md +0 -37
  298. package/kb-template/Pipelines/.gitkeep +0 -0
  299. package/kb-template/Pipelines/README.md +0 -38
  300. package/kb-template/Skills/.gitkeep +0 -0
  301. package/kb-template/Skills/README.md +0 -32
  302. package/kb-template/Tools/.gitkeep +0 -0
  303. package/kb-template/Tools/README.md +0 -42
  304. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -68,7 +68,7 @@ describe('ToolManualService', () => {
68
68
 
69
69
  beforeEach(async () => {
70
70
  root = await mkdtemp(join(tmpdir(), 'tools-'));
71
- const tools = join(root, wsId, KB_DIR, 'Tools');
71
+ const tools = join(root, wsId, KB_DIR, 'Groups');
72
72
  await mkdir(tools, { recursive: true });
73
73
  await writeFile(join(tools, 'weather.tool'), INLINE_TOOL);
74
74
  await writeFile(join(tools, 'billing.tool'), HTTP_TOOL);
@@ -87,6 +87,16 @@ describe('ToolManualService', () => {
87
87
  expect(list.map((m) => m.name)).toEqual(['weather']);
88
88
  });
89
89
 
90
+ test('listAllSummaries returns every manual regardless of caller access', async () => {
91
+ // The group index counts a group's tools for people who cannot read them,
92
+ // so this surface must ignore the ACL that `listAccessible` applies.
93
+ const service = svc(denyBilling);
94
+ expect((await service.listAccessible('user@x.eu')).map((m) => m.name)).toEqual(['weather']);
95
+ const all = await service.listAllSummaries();
96
+ expect(all.map((m) => m.name).sort()).toEqual(['billing', 'weather']);
97
+ expect(all.map((m) => m.path).sort()).toEqual(['Groups/billing.tool', 'Groups/weather.tool']);
98
+ });
99
+
90
100
  test('builds an inline manual as an http sub-manual call-template', async () => {
91
101
  const templates = await svc().toManualCallTemplates('user@x.eu');
92
102
  const inline = templates.find((t) => t.name === 'weather')!;
@@ -113,7 +123,7 @@ describe('ToolManualService', () => {
113
123
 
114
124
  test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
115
125
  root = await mkdtemp(join(tmpdir(), 'tools2-'));
116
- const tools = join(root, wsId, KB_DIR, 'Tools');
126
+ const tools = join(root, wsId, KB_DIR, 'Groups');
117
127
  await mkdir(tools, { recursive: true });
118
128
  await writeFile(join(tools, 'my_cool-tool.tool'), JSON.stringify({ type: 'http', url: 'https://x.example.com/m' }));
119
129
  const list = await svc().listAccessible('user@x.eu');
@@ -123,7 +133,7 @@ describe('ToolManualService', () => {
123
133
 
124
134
  test('refuses a `.tool` whose manual name collides (no silent suffix)', async () => {
125
135
  root = await mkdtemp(join(tmpdir(), 'tools3-'));
126
- const tools = join(root, wsId, KB_DIR, 'Tools');
136
+ const tools = join(root, wsId, KB_DIR, 'Groups');
127
137
  await mkdir(tools, { recursive: true });
128
138
  // Both names normalize to `stripe`. The scanner keeps the first (sorted path
129
139
  // order) and REFUSES the duplicate rather than renaming it `stripe2` — a
@@ -134,12 +144,12 @@ describe('ToolManualService', () => {
134
144
  await writeFile(join(tools, 'b.tool'), JSON.stringify({ name: 'stripe!', type: 'http', url: 'https://b.example.com/m' }));
135
145
  const list = await svc().listAccessible('user@x.eu');
136
146
  expect(list.map((m) => m.name)).toEqual(['stripe']);
137
- expect(list[0].path).toBe('Tools/a.tool');
147
+ expect(list[0].path).toBe('Groups/a.tool');
138
148
  });
139
149
 
140
150
  test('parses `variables` scopes (default admin, explicit user/admin)', async () => {
141
151
  root = await mkdtemp(join(tmpdir(), 'toolsv-'));
142
- const tools = join(root, wsId, KB_DIR, 'Tools');
152
+ const tools = join(root, wsId, KB_DIR, 'Groups');
143
153
  await mkdir(tools, { recursive: true });
144
154
  await writeFile(
145
155
  join(tools, 'weather.tool'),
@@ -164,7 +174,7 @@ describe('ToolManualService', () => {
164
174
 
165
175
  test('skips a `.tool` with a malformed `variables` entry (never silently mis-scoped)', async () => {
166
176
  root = await mkdtemp(join(tmpdir(), 'toolsvbad-'));
167
- const tools = join(root, wsId, KB_DIR, 'Tools');
177
+ const tools = join(root, wsId, KB_DIR, 'Groups');
168
178
  await mkdir(tools, { recursive: true });
169
179
  await writeFile(
170
180
  join(tools, 'weather.tool'),
@@ -175,7 +185,7 @@ describe('ToolManualService', () => {
175
185
 
176
186
  test('scopeOfVariable: declared scope wins; undeclared/unknown default to admin', async () => {
177
187
  root = await mkdtemp(join(tmpdir(), 'toolsscope-'));
178
- const tools = join(root, wsId, KB_DIR, 'Tools');
188
+ const tools = join(root, wsId, KB_DIR, 'Groups');
179
189
  await mkdir(tools, { recursive: true });
180
190
  await writeFile(
181
191
  join(tools, 'weather.tool'),
@@ -211,7 +221,7 @@ describe('ToolManualService', () => {
211
221
  });
212
222
  const writeOAuthTool = async (variable: unknown) => {
213
223
  root = await mkdtemp(join(tmpdir(), 'toolsoauth-'));
214
- const tools = join(root, wsId, KB_DIR, 'Tools');
224
+ const tools = join(root, wsId, KB_DIR, 'Groups');
215
225
  await mkdir(tools, { recursive: true });
216
226
  await writeFile(
217
227
  join(tools, 'g.tool'),
@@ -258,7 +268,7 @@ describe('ToolManualService', () => {
258
268
 
259
269
  test('parses `remote`: default true, explicit false, rejects non-boolean', async () => {
260
270
  root = await mkdtemp(join(tmpdir(), 'toolsrem-'));
261
- const tools = join(root, wsId, KB_DIR, 'Tools');
271
+ const tools = join(root, wsId, KB_DIR, 'Groups');
262
272
  await mkdir(tools, { recursive: true });
263
273
  await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
264
274
  await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'mcp', url: 'https://x/m', remote: false }));
@@ -271,21 +281,21 @@ describe('ToolManualService', () => {
271
281
 
272
282
  test('remoteOnly excludes local-only manuals; listLocalOnly returns them', async () => {
273
283
  root = await mkdtemp(join(tmpdir(), 'toolsrem2-'));
274
- const tools = join(root, wsId, KB_DIR, 'Tools');
284
+ const tools = join(root, wsId, KB_DIR, 'Groups');
275
285
  await mkdir(tools, { recursive: true });
276
286
  await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
277
287
  await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'http', url: 'https://x/m', remote: false }));
278
288
  const s = svc();
279
289
  expect((await s.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name).sort()).toEqual(['pub']);
280
290
  expect((await s.toManualCallTemplates('user@x.eu')).map((t) => t.name).sort()).toEqual(['loc', 'pub']);
281
- expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Tools/loc.tool' }]);
291
+ expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Groups/loc.tool' }]);
282
292
  // The local-only manual is still browsable/editable regardless of the remote flag.
283
293
  expect((await s.listAccessible('user@x.eu')).map((m) => m.name).sort()).toEqual(['loc', 'pub']);
284
294
  });
285
295
 
286
296
  test('refuses a `.tool` that reproduces a reserved built-in namespace', async () => {
287
297
  root = await mkdtemp(join(tmpdir(), 'toolsresv-'));
288
- const tools = join(root, wsId, KB_DIR, 'Tools');
298
+ const tools = join(root, wsId, KB_DIR, 'Groups');
289
299
  await mkdir(tools, { recursive: true });
290
300
  // A `.tool` named exactly like the internal manual, or with the KB namespace as
291
301
  // an explicit id, would resolve the loopback creds seeded under that namespace.
@@ -298,7 +308,7 @@ describe('ToolManualService', () => {
298
308
 
299
309
  test('refuses a remote `.tool` whose url is a private/loopback/metadata host (SSRF)', async () => {
300
310
  root = await mkdtemp(join(tmpdir(), 'toolsssrf-'));
301
- const tools = join(root, wsId, KB_DIR, 'Tools');
311
+ const tools = join(root, wsId, KB_DIR, 'Groups');
302
312
  await mkdir(tools, { recursive: true });
303
313
  await writeFile(join(tools, 'imds.tool'), JSON.stringify({ name: 'imds', type: 'http', url: 'http://169.254.169.254/latest/meta-data' }));
304
314
  await writeFile(join(tools, 'loop.tool'), JSON.stringify({ name: 'loop', type: 'mcp', url: 'http://127.0.0.1:9000/mcp' }));
@@ -312,7 +322,7 @@ describe('ToolManualService', () => {
312
322
 
313
323
  test('SSRF guard still applies when a private host is combined with a template token in the query', async () => {
314
324
  root = await mkdtemp(join(tmpdir(), 'toolsssrf3-'));
315
- const tools = join(root, wsId, KB_DIR, 'Tools');
325
+ const tools = join(root, wsId, KB_DIR, 'Groups');
316
326
  await mkdir(tools, { recursive: true });
317
327
  // A `${VAR}` in the QUERY doesn't make the authority templated — the host is
318
328
  // still the literal metadata endpoint, so the guard must reject it. Only a
@@ -325,7 +335,7 @@ describe('ToolManualService', () => {
325
335
 
326
336
  test('refuses any `.tool` that references a platform-seeded variable (API_URL / CONNECTION_KEY)', async () => {
327
337
  root = await mkdtemp(join(tmpdir(), 'toolsresvvar-'));
328
- const tools = join(root, wsId, KB_DIR, 'Tools');
338
+ const tools = join(root, wsId, KB_DIR, 'Groups');
329
339
  await mkdir(tools, { recursive: true });
330
340
  // Both reference forms (`${VAR}` and `$VAR`), in any `.tool` type and any
331
341
  // field — url query, header, an inline tool's call template. All refused at
@@ -348,7 +358,7 @@ describe('ToolManualService', () => {
348
358
 
349
359
  test('refuses a `.tool` that references a NAMESPACED reserved variable (`<ns>_API_URL` / `<ns>_CONNECTION_KEY`)', async () => {
350
360
  root = await mkdtemp(join(tmpdir(), 'toolsresvns-'));
351
- const tools = join(root, wsId, KB_DIR, 'Tools');
361
+ const tools = join(root, wsId, KB_DIR, 'Groups');
352
362
  await mkdir(tools, { recursive: true });
353
363
  // The substitutor resolves a manual's variables under its UTCP namespace, so
354
364
  // `${BEVEL_CONNECTION_KEY}` reaches the same seeded platform bearer the bare
@@ -376,7 +386,7 @@ describe('ToolManualService', () => {
376
386
 
377
387
  test('refuses a `.tool` that declares a variable named API_URL or CONNECTION_KEY', async () => {
378
388
  root = await mkdtemp(join(tmpdir(), 'toolsresvdecl-'));
379
- const tools = join(root, wsId, KB_DIR, 'Tools');
389
+ const tools = join(root, wsId, KB_DIR, 'Groups');
380
390
  await mkdir(tools, { recursive: true });
381
391
  await writeFile(
382
392
  join(tools, 'decl.tool'),
@@ -392,7 +402,7 @@ describe('ToolManualService', () => {
392
402
 
393
403
  test('SSRF guard validates the literal host even when userinfo or port are templated', async () => {
394
404
  root = await mkdtemp(join(tmpdir(), 'toolsssrf4-'));
395
- const tools = join(root, wsId, KB_DIR, 'Tools');
405
+ const tools = join(root, wsId, KB_DIR, 'Groups');
396
406
  await mkdir(tools, { recursive: true });
397
407
  // A `${VAR}` in the USERINFO or PORT doesn't make the hostname dynamic — the
398
408
  // fetch still targets the literal metadata IP, so the guard must reject it.
@@ -417,7 +427,7 @@ describe('ToolManualService', () => {
417
427
 
418
428
  test('SSRF guard exempts a local-only (remote:false) `.tool` and a templated url', async () => {
419
429
  root = await mkdtemp(join(tmpdir(), 'toolsssrf2-'));
420
- const tools = join(root, wsId, KB_DIR, 'Tools');
430
+ const tools = join(root, wsId, KB_DIR, 'Groups');
421
431
  await mkdir(tools, { recursive: true });
422
432
  // remote:false is never fetched server-side; a `${VAR}` url resolves at call time.
423
433
  await writeFile(join(tools, 'local.tool'), JSON.stringify({ name: 'localmcp', type: 'mcp', url: 'http://localhost:3333/mcp', remote: false }));
@@ -437,7 +447,7 @@ describe('ToolManualService', () => {
437
447
 
438
448
  test('parses `.tool` frontmatter: id is the manual name/namespace', async () => {
439
449
  root = await mkdtemp(join(tmpdir(), 'toolsfm-'));
440
- const tools = join(root, wsId, KB_DIR, 'Tools');
450
+ const tools = join(root, wsId, KB_DIR, 'Groups');
441
451
  await mkdir(tools, { recursive: true });
442
452
  // The tool IS the frontmatter: id + config in the one `---` block.
443
453
  await writeFile(
@@ -451,7 +461,7 @@ describe('ToolManualService', () => {
451
461
 
452
462
  test('snake_case id resolves scope via the UTCP doubled key', async () => {
453
463
  root = await mkdtemp(join(tmpdir(), 'toolssnake-'));
454
- const tools = join(root, wsId, KB_DIR, 'Tools');
464
+ const tools = join(root, wsId, KB_DIR, 'Groups');
455
465
  await mkdir(tools, { recursive: true });
456
466
  await writeFile(
457
467
  join(tools, 't.tool'),
@@ -465,7 +475,7 @@ describe('ToolManualService', () => {
465
475
 
466
476
  test('rejects a non-snake_case explicit id (file skipped)', async () => {
467
477
  root = await mkdtemp(join(tmpdir(), 'toolsbadid-'));
468
- const tools = join(root, wsId, KB_DIR, 'Tools');
478
+ const tools = join(root, wsId, KB_DIR, 'Groups');
469
479
  await mkdir(tools, { recursive: true });
470
480
  await writeFile(join(tools, 'x.tool'), '---\nid: My-Tool\ntype: http\nurl: https://x/m\n---\n');
471
481
  expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
@@ -473,7 +483,7 @@ describe('ToolManualService', () => {
473
483
 
474
484
  test('config after the closing fence is ignored (the tool is the frontmatter)', async () => {
475
485
  root = await mkdtemp(join(tmpdir(), 'toolsnotes-'));
476
- const tools = join(root, wsId, KB_DIR, 'Tools');
486
+ const tools = join(root, wsId, KB_DIR, 'Groups');
477
487
  await mkdir(tools, { recursive: true });
478
488
  // `type`/`url` live in the fence; the JSON after it is free-form notes.
479
489
  await writeFile(
@@ -485,6 +495,34 @@ describe('ToolManualService', () => {
485
495
  expect(m.type).toBe('mcp');
486
496
  });
487
497
 
498
+ test('parses `description` onto the descriptor, trimmed', async () => {
499
+ root = await mkdtemp(join(tmpdir(), 'toolsdesc-'));
500
+ const tools = join(root, wsId, KB_DIR, 'Groups');
501
+ await mkdir(tools, { recursive: true });
502
+ await writeFile(
503
+ join(tools, 'gh.tool'),
504
+ '---\nid: gh\ntype: mcp\nurl: https://mcp.example.com\ndescription: " Read and write issues. "\n---\n',
505
+ );
506
+ const [m] = await svc().listAccessible('user@x.eu');
507
+ expect(m.description).toBe('Read and write issues.');
508
+ });
509
+
510
+ test('a malformed `description` is IGNORED — it never takes a working `.tool` offline', async () => {
511
+ // Every other field throws on a bad value, which skips the whole file. This
512
+ // one must not: `description` buys a reader one sentence, and no sentence is
513
+ // worth removing a working integration from the catalog.
514
+ root = await mkdtemp(join(tmpdir(), 'toolsdescbad-'));
515
+ const tools = join(root, wsId, KB_DIR, 'Groups');
516
+ await mkdir(tools, { recursive: true });
517
+ await writeFile(join(tools, 'num.tool'), JSON.stringify({ name: 'num', type: 'http', url: 'https://x/m', description: 42 }));
518
+ await writeFile(join(tools, 'obj.tool'), JSON.stringify({ name: 'obj', type: 'http', url: 'https://x/m', description: { a: 1 } }));
519
+ await writeFile(join(tools, 'nul.tool'), JSON.stringify({ name: 'nul', type: 'http', url: 'https://x/m', description: null }));
520
+ await writeFile(join(tools, 'blank.tool'), JSON.stringify({ name: 'blank', type: 'http', url: 'https://x/m', description: ' ' }));
521
+ const byName = new Map((await svc().listAccessible('user@x.eu')).map((m) => [m.name, m]));
522
+ expect([...byName.keys()].sort()).toEqual(['blank', 'nul', 'num', 'obj']); // all four still in the catalog
523
+ for (const m of byName.values()) expect(m.description).toBeUndefined();
524
+ });
525
+
488
526
  test('preview validates an inline draft and reports its tools', async () => {
489
527
  const preview = await svc().preview(INLINE_TOOL);
490
528
  expect(preview.ok).toBe(true);
@@ -1,156 +1,156 @@
1
- import type { Server as HttpServer } from 'node:http';
2
- import express from 'express';
3
- import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { ToolRegistry } from '../../tool-registry/tool-registry.js';
5
- import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
6
- import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
7
- import { createToolContextResolver } from '../../tool-helpers/tool-context.js';
8
- import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
9
- import { registerToolManualsTools } from '../tool-manuals.tools.js';
10
- import type { IToolManualService } from '../tool-manuals.contract.js';
11
-
12
- /**
13
- * `list_tool_setup` MUST respect the same access controls as every other tool
14
- * surface, resolved for THE CALLER:
15
- *
16
- * - the catalog is read-gated: a `.tool` the caller can't READ is absent
17
- * (via `listAccessible(callerEmail)` — default-deny, per email);
18
- * - `canWrite` is the PER-FILE verdict (`canWrite(email, path)`), not a role;
19
- * - configuration status is the caller's own (`statusFor(callerUserId, …)`).
20
- *
21
- * Exercised over a real express app with the real tool auth + handler
22
- * machinery, as two different users, so a caller-mixup (leaking another user's
23
- * catalog or status) can't slip through the composition.
24
- */
25
-
26
- const ALICE = { id: 'user-alice', email: 'alice@x.com', name: 'Alice' };
27
- const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
28
-
29
- // Alice reads both tools and may write weather; Bob reads only weather, writes nothing.
30
- const CATALOG = [
31
- {
32
- slug: 'weather',
33
- name: 'weather',
34
- path: 'Tools/weather.tool',
35
- type: 'mcp' as const,
36
- setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
37
- variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
38
- },
39
- {
40
- slug: 'billing',
41
- name: 'billing',
42
- path: 'Tools/billing.tool',
43
- type: 'http' as const,
44
- variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
45
- },
46
- ];
47
-
48
- const toolManualService = {
49
- listAccessible: vi.fn(async (email: string) =>
50
- email === ALICE.email ? CATALOG : CATALOG.filter((m) => m.slug === 'weather'),
51
- ),
52
- listLocalOnly: async () => [],
53
- } as unknown as IToolManualService;
54
-
55
- const accessControl = {
56
- canWrite: vi.fn(
57
- async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Tools/weather.tool',
58
- ),
59
- } as never;
60
-
61
- const statusFor = vi.fn(async (userId: string, keys: string[]) =>
62
- keys.map((key) => ({
63
- key,
64
- // Only Alice has configured anything — Bob's view must not inherit it.
65
- adminConfigured: true,
66
- userConfigured: userId === ALICE.id,
67
- })),
68
- );
69
-
70
- let httpServer: HttpServer | undefined;
71
-
72
- const externalApiKeyService = {
73
- looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
74
- verifyAndLoadToken: async (t: string) =>
75
- t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
76
- } as never;
77
-
78
- async function start(): Promise<string> {
79
- const registry = new ToolRegistry();
80
- const internalToken = new InternalTokenService({ secret: 'test-secret' });
81
- const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
82
- const resolve = createToolContextResolver({
83
- authService: {
84
- getUserById: async (id: string) => (id === ALICE.id ? ALICE : id === BOB.id ? BOB : null),
85
- } as never,
86
- workspaceService: { getWorkspacePath: async () => '/tmp/ws' } as never,
87
- workflowService: {} as never,
88
- events: {} as never,
89
- kbDirName: 'knowledge-base',
90
- creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} },
91
- });
92
- const toolHandler = createToolHandlerFactory(resolve);
93
-
94
- const app = express();
95
- app.use(express.json());
96
- const router = express.Router();
97
- registerToolManualsTools(registry, router, toolAuth, toolHandler, toolManualService, {
98
- accessControl,
99
- variableStatus: { statusFor },
100
- });
101
- app.use('/api', router);
102
-
103
- httpServer = await new Promise<HttpServer>((r) => {
104
- const s = app.listen(0, () => r(s));
105
- });
106
- const port = (httpServer.address() as { port: number }).port;
107
- return `http://127.0.0.1:${port}`;
108
- }
109
-
110
- afterEach(async () => {
111
- if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
112
- httpServer = undefined;
113
- vi.clearAllMocks();
114
- });
115
-
116
- const callSetup = (base: string, bearer: string) =>
117
- fetch(`${base}/api/agent/tools/list_tool_setup`, {
118
- method: 'POST',
119
- headers: { 'content-type': 'application/json', authorization: `Bearer ${bearer}` },
120
- body: JSON.stringify({}),
121
- });
122
-
123
- describe('list_tool_setup — access controls resolved for the caller', () => {
124
- it('shows each caller only the tools THEY can read, with THEIR per-file canWrite', async () => {
125
- const base = await start();
126
-
127
- const aliceRes = await callSetup(base, 'bevel_alice');
128
- expect(aliceRes.status).toBe(200);
129
- const alice = (await aliceRes.json()) as { tools: { slug: string; canWrite: boolean }[] };
130
- expect(alice.tools.map((t) => t.slug).sort()).toEqual(['billing', 'weather']);
131
- expect(alice.tools.find((t) => t.slug === 'weather')!.canWrite).toBe(true);
132
- expect(alice.tools.find((t) => t.slug === 'billing')!.canWrite).toBe(false);
133
-
134
- const bobRes = await callSetup(base, 'bevel_bob');
135
- expect(bobRes.status).toBe(200);
136
- const bob = (await bobRes.json()) as {
137
- tools: { slug: string; canWrite: boolean; variables: { userConfigured: boolean }[] }[];
138
- };
139
- // Bob can't read billing — it must be absent, not just canWrite=false.
140
- expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
141
- expect(bob.tools[0].canWrite).toBe(false);
142
- // Status was resolved for BOB's user id, not leaked from Alice's.
143
- expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['weather_SHARED_KEY']);
144
- expect(bob.tools[0].variables[0].userConfigured).toBe(false);
145
- });
146
-
147
- it('rejects an unauthenticated call outright', async () => {
148
- const base = await start();
149
- const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
150
- method: 'POST',
151
- headers: { 'content-type': 'application/json' },
152
- body: JSON.stringify({}),
153
- });
154
- expect(res.status).toBe(401);
155
- });
156
- });
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
5
+ import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
6
+ import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
7
+ import { createToolContextResolver } from '../../tool-helpers/tool-context.js';
8
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
9
+ import { registerToolManualsTools } from '../tool-manuals.tools.js';
10
+ import type { IToolManualService } from '../tool-manuals.contract.js';
11
+
12
+ /**
13
+ * `list_tool_setup` MUST respect the same access controls as every other tool
14
+ * surface, resolved for THE CALLER:
15
+ *
16
+ * - the catalog is read-gated: a `.tool` the caller can't READ is absent
17
+ * (via `listAccessible(callerEmail)` — default-deny, per email);
18
+ * - `canWrite` is the PER-FILE verdict (`canWrite(email, path)`), not a role;
19
+ * - configuration status is the caller's own (`statusFor(callerUserId, …)`).
20
+ *
21
+ * Exercised over a real express app with the real tool auth + handler
22
+ * machinery, as two different users, so a caller-mixup (leaking another user's
23
+ * catalog or status) can't slip through the composition.
24
+ */
25
+
26
+ const ALICE = { id: 'user-alice', email: 'alice@x.com', name: 'Alice' };
27
+ const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
28
+
29
+ // Alice reads both tools and may write weather; Bob reads only weather, writes nothing.
30
+ const CATALOG = [
31
+ {
32
+ slug: 'weather',
33
+ name: 'weather',
34
+ path: 'Groups/weather.tool',
35
+ type: 'mcp' as const,
36
+ setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
37
+ variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
38
+ },
39
+ {
40
+ slug: 'billing',
41
+ name: 'billing',
42
+ path: 'Groups/billing.tool',
43
+ type: 'http' as const,
44
+ variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
45
+ },
46
+ ];
47
+
48
+ const toolManualService = {
49
+ listAccessible: vi.fn(async (email: string) =>
50
+ email === ALICE.email ? CATALOG : CATALOG.filter((m) => m.slug === 'weather'),
51
+ ),
52
+ listLocalOnly: async () => [],
53
+ } as unknown as IToolManualService;
54
+
55
+ const accessControl = {
56
+ canWrite: vi.fn(
57
+ async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Groups/weather.tool',
58
+ ),
59
+ } as never;
60
+
61
+ const statusFor = vi.fn(async (userId: string, keys: string[]) =>
62
+ keys.map((key) => ({
63
+ key,
64
+ // Only Alice has configured anything — Bob's view must not inherit it.
65
+ adminConfigured: true,
66
+ userConfigured: userId === ALICE.id,
67
+ })),
68
+ );
69
+
70
+ let httpServer: HttpServer | undefined;
71
+
72
+ const externalApiKeyService = {
73
+ looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
74
+ verifyAndLoadToken: async (t: string) =>
75
+ t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
76
+ } as never;
77
+
78
+ async function start(): Promise<string> {
79
+ const registry = new ToolRegistry();
80
+ const internalToken = new InternalTokenService({ secret: 'test-secret' });
81
+ const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
82
+ const resolve = createToolContextResolver({
83
+ authService: {
84
+ getUserById: async (id: string) => (id === ALICE.id ? ALICE : id === BOB.id ? BOB : null),
85
+ } as never,
86
+ workspaceService: { getWorkspacePath: async () => '/tmp/ws' } as never,
87
+ workflowService: {} as never,
88
+ events: {} as never,
89
+ kbDirName: 'knowledge-base',
90
+ creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} },
91
+ });
92
+ const toolHandler = createToolHandlerFactory(resolve);
93
+
94
+ const app = express();
95
+ app.use(express.json());
96
+ const router = express.Router();
97
+ registerToolManualsTools(registry, router, toolAuth, toolHandler, toolManualService, {
98
+ accessControl,
99
+ variableStatus: { statusFor },
100
+ });
101
+ app.use('/api', router);
102
+
103
+ httpServer = await new Promise<HttpServer>((r) => {
104
+ const s = app.listen(0, () => r(s));
105
+ });
106
+ const port = (httpServer.address() as { port: number }).port;
107
+ return `http://127.0.0.1:${port}`;
108
+ }
109
+
110
+ afterEach(async () => {
111
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
112
+ httpServer = undefined;
113
+ vi.clearAllMocks();
114
+ });
115
+
116
+ const callSetup = (base: string, bearer: string) =>
117
+ fetch(`${base}/api/agent/tools/list_tool_setup`, {
118
+ method: 'POST',
119
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${bearer}` },
120
+ body: JSON.stringify({}),
121
+ });
122
+
123
+ describe('list_tool_setup — access controls resolved for the caller', () => {
124
+ it('shows each caller only the tools THEY can read, with THEIR per-file canWrite', async () => {
125
+ const base = await start();
126
+
127
+ const aliceRes = await callSetup(base, 'bevel_alice');
128
+ expect(aliceRes.status).toBe(200);
129
+ const alice = (await aliceRes.json()) as { tools: { slug: string; canWrite: boolean }[] };
130
+ expect(alice.tools.map((t) => t.slug).sort()).toEqual(['billing', 'weather']);
131
+ expect(alice.tools.find((t) => t.slug === 'weather')!.canWrite).toBe(true);
132
+ expect(alice.tools.find((t) => t.slug === 'billing')!.canWrite).toBe(false);
133
+
134
+ const bobRes = await callSetup(base, 'bevel_bob');
135
+ expect(bobRes.status).toBe(200);
136
+ const bob = (await bobRes.json()) as {
137
+ tools: { slug: string; canWrite: boolean; variables: { userConfigured: boolean }[] }[];
138
+ };
139
+ // Bob can't read billing — it must be absent, not just canWrite=false.
140
+ expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
141
+ expect(bob.tools[0].canWrite).toBe(false);
142
+ // Status was resolved for BOB's user id, not leaked from Alice's.
143
+ expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['weather_SHARED_KEY']);
144
+ expect(bob.tools[0].variables[0].userConfigured).toBe(false);
145
+ });
146
+
147
+ it('rejects an unauthenticated call outright', async () => {
148
+ const base = await start();
149
+ const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({}),
153
+ });
154
+ expect(res.status).toBe(401);
155
+ });
156
+ });
@@ -8,5 +8,7 @@ export {
8
8
  EXTERNAL_KB_MANUAL_NAME,
9
9
  type IToolManualService,
10
10
  type ToolManualSummary,
11
+ type ToolManualDetail,
12
+ type ToolCapability,
11
13
  type ToolManualType,
12
14
  } from './tool-manuals.contract.js';
@@ -1,7 +1,7 @@
1
1
  import type { CallTemplate } from '@utcp/sdk';
2
2
 
3
3
  /**
4
- * Tool manuals — user-authored `*.tool` files under `Tools/` in the DEFAULT
4
+ * Tool manuals — user-authored `*.tool` files under `Groups/` in the DEFAULT
5
5
  * branch KB. Each is a UTCP *manual* (a pointer to where tools come from), not a
6
6
  * flattened tool list. Access-controlled exactly like Skills (default-deny ACL
7
7
  * on the `.tool` file path). The MCP/UTCP endpoint serves every manual a user
@@ -95,9 +95,16 @@ export interface ToolManualDescriptor {
95
95
  * catalog; namespacing doubles underscores in vault keys (`utcpNamespacedKey`).
96
96
  */
97
97
  name: string;
98
- /** Repo-root-relative path of the `.tool` file (e.g. `Tools/weather.tool`). */
98
+ /** Repo-root-relative path of the `.tool` file (e.g. `Groups/Everyone/weather.tool`). */
99
99
  path: string;
100
100
  type: ToolManualType;
101
+ /**
102
+ * Optional one-line prose from the frontmatter, for humans browsing the
103
+ * catalog. PURELY COSMETIC — a malformed value is ignored rather than
104
+ * skipping the file (see `normalizeToolManual`), because a bad sentence must
105
+ * never take a working integration offline.
106
+ */
107
+ description?: string;
101
108
  /** For `http`/`mcp`: the endpoint URL (may contain `${VAR}` refs). */
102
109
  url?: string;
103
110
  httpMethod?: 'GET' | 'POST';
@@ -134,6 +141,8 @@ export interface ToolManualSummary {
134
141
  name: string;
135
142
  path: string;
136
143
  type: ToolManualType;
144
+ /** The frontmatter `description`, when the file declares a usable one. */
145
+ description?: string;
137
146
  /** Declared `${VAR}` scopes; empty when none declared. */
138
147
  variables?: ToolVariable[];
139
148
  /** `false` ⇒ local-only (not served to remote agents); absent/`true` ⇒ remote-capable. */
@@ -142,9 +151,48 @@ export interface ToolManualSummary {
142
151
  setup?: ToolManualSetup;
143
152
  }
144
153
 
154
+ /** One thing an `inline` manual's embedded tool list says the assistant can do. */
155
+ export interface ToolCapability {
156
+ name: string;
157
+ description: string | null;
158
+ }
159
+
160
+ /**
161
+ * A single tool manual for the BROWSER tool page (`GET /api/tools/:slug`): the
162
+ * summary plus the two human-facing fields the catalog listing has no use for.
163
+ * Both are normalized to `null` rather than left optional — the page renders a
164
+ * definite "nothing here" state, so an absent field and an empty one are the
165
+ * same thing to it.
166
+ */
167
+ export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'> {
168
+ description: string | null;
169
+ /**
170
+ * What the tool actually exposes, derived from an `inline` manual's embedded
171
+ * `tools`. `http`/`mcp` manuals resolve their tools at call time (a network
172
+ * round-trip this endpoint deliberately does not make), so they report `[]`.
173
+ */
174
+ capabilities: ToolCapability[];
175
+ }
176
+
145
177
  export interface IToolManualService {
146
178
  /** The `.tool` manuals the user can read, as summaries. */
147
179
  listAccessible(userEmail: string): Promise<ToolManualSummary[]>;
180
+ /**
181
+ * One readable `.tool` by slug, with its description + capabilities, for the
182
+ * browser tool page. `null` when no such slug exists OR the caller can't read
183
+ * it — the two are deliberately indistinguishable (fail-closed: a 404 must not
184
+ * confirm that a tool the caller can't see exists).
185
+ */
186
+ getDetail(userEmail: string, slug: string): Promise<ToolManualDetail | null>;
187
+
188
+ /**
189
+ * Every manual in the catalog, UNFILTERED by access — the mirror of
190
+ * `skillService.listSkills(undefined)`. For caller-INDEPENDENT counting only
191
+ * (the group index's "N tools", which a non-member is allowed to see as a
192
+ * number). Never surface a name, path or description from this to someone
193
+ * who cannot read the file; `listAccessible` is the surface for that.
194
+ */
195
+ listAllSummaries(): Promise<ToolManualSummary[]>;
148
196
  /**
149
197
  * Validated UTCP manual call-templates for the user's accessible `.tool`s —
150
198
  * one per file, ready for a UTCP client to `registerManual`. Each is validated