@bevel-software/platform-core-backend 0.7.5 → 0.9.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 (282) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +8 -1
  4. package/dist/core/core-ports.d.ts.map +1 -1
  5. package/dist/core/core-ports.js.map +1 -1
  6. package/dist/core/create-core-server.d.ts.map +1 -1
  7. package/dist/core/create-core-server.js +75 -18
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +12 -5
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +69 -36
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/modules/access/access-control.interface.d.ts +8 -7
  14. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts +1 -1
  16. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.js +6 -6
  18. package/dist/modules/access/access-control.service.js.map +1 -1
  19. package/dist/modules/access/access-declarations.d.ts +5 -5
  20. package/dist/modules/access/access-declarations.js +3 -3
  21. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +3 -3
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.js +4 -4
  26. package/dist/modules/access/access-splice.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts +3 -1
  28. package/dist/modules/access/access.routes.d.ts.map +1 -1
  29. package/dist/modules/access/access.routes.js +22 -20
  30. package/dist/modules/access/access.routes.js.map +1 -1
  31. package/dist/modules/access/creator-access.d.ts +2 -2
  32. package/dist/modules/access/creator-access.js +5 -5
  33. package/dist/modules/access/creator-access.js.map +1 -1
  34. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  35. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  36. package/dist/modules/access/render-roles-yaml.js +56 -0
  37. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  38. package/dist/modules/access/roles-admin.service.d.ts +33 -5
  39. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  40. package/dist/modules/access/roles-admin.service.js +44 -19
  41. package/dist/modules/access/roles-admin.service.js.map +1 -1
  42. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  43. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  44. package/dist/modules/code-mode/code-mode-names.js +5 -27
  45. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  46. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  47. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  48. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  49. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  50. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  51. package/dist/modules/mcp/mcp.service.js +33 -395
  52. package/dist/modules/mcp/mcp.service.js.map +1 -1
  53. package/dist/modules/plugins/index.d.ts +7 -0
  54. package/dist/modules/plugins/index.d.ts.map +1 -0
  55. package/dist/modules/plugins/index.js +6 -0
  56. package/dist/modules/plugins/index.js.map +1 -0
  57. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  58. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  59. package/dist/modules/plugins/join-proposals.js +67 -0
  60. package/dist/modules/plugins/join-proposals.js.map +1 -0
  61. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  62. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  63. package/dist/modules/plugins/join-requests.service.js +135 -0
  64. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  65. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  66. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  68. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  69. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  70. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.contract.js +36 -0
  72. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  73. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  74. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  75. package/dist/modules/plugins/plugins.routes.js +379 -0
  76. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  77. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  78. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  79. package/dist/modules/plugins/plugins.service.js +172 -0
  80. package/dist/modules/plugins/plugins.service.js.map +1 -0
  81. package/dist/modules/settings/setup.routes.d.ts +10 -1
  82. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  83. package/dist/modules/settings/setup.routes.js +111 -6
  84. package/dist/modules/settings/setup.routes.js.map +1 -1
  85. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  86. package/dist/modules/skills/pending-skills.service.js +7 -7
  87. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  88. package/dist/modules/skills/skills.contract.d.ts +4 -4
  89. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  90. package/dist/modules/skills/skills.contract.js +1 -1
  91. package/dist/modules/skills/skills.service.js +5 -5
  92. package/dist/modules/skills/skills.service.js.map +1 -1
  93. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  94. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  95. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  96. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  97. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  98. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  99. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  100. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  101. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  102. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  103. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  104. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  105. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  106. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  107. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  108. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  109. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  110. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  111. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  112. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  113. package/dist/modules/workflow/git/git.service.js +2 -2
  114. package/dist/modules/workflow/git/git.service.js.map +1 -1
  115. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  116. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  117. package/dist/modules/workspace/kb-seed.service.js +43 -10
  118. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  119. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  120. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  121. package/dist/modules/workspace/plugins-migration.js +379 -0
  122. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  123. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  124. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  125. package/dist/modules/workspace/startup/kb-git.js +86 -0
  126. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  127. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  128. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  129. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  130. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  131. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  132. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  133. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  134. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  135. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  136. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  137. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  138. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  139. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  140. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  141. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  142. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  143. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  144. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  145. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  146. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  147. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  148. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  149. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  150. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  151. package/dist/modules/workspace/workspace.routes.js +3 -3
  152. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  153. package/dist/modules/workspace/workspace.service.d.ts +0 -9
  154. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  155. package/dist/modules/workspace/workspace.service.js +2 -30
  156. package/dist/modules/workspace/workspace.service.js.map +1 -1
  157. package/dist/shared/kb-layout.test.js +3 -3
  158. package/dist/shared/kb-layout.test.js.map +1 -1
  159. package/dist/shared/utcp-namespace.d.ts +6 -27
  160. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  161. package/dist/shared/utcp-namespace.js +6 -63
  162. package/dist/shared/utcp-namespace.js.map +1 -1
  163. package/dist/shared/variable-refs.d.ts +42 -0
  164. package/dist/shared/variable-refs.d.ts.map +1 -0
  165. package/dist/shared/variable-refs.js +60 -0
  166. package/dist/shared/variable-refs.js.map +1 -0
  167. package/kb-template/.bevelignore +1 -1
  168. package/kb-template/AGENTS.md +88 -35
  169. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  170. package/kb-template/access.md +36 -36
  171. package/kb-template/gitignore.template +16 -0
  172. package/migrations/meta/0000_snapshot.json +1479 -1479
  173. package/package.json +5 -4
  174. package/src/assets.ts +25 -25
  175. package/src/core/core-ports.ts +113 -106
  176. package/src/core/create-core-server.ts +85 -20
  177. package/src/core/create-core-services.ts +83 -42
  178. package/src/index.ts +69 -69
  179. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  180. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  181. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  182. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  183. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  184. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  185. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  186. package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
  187. package/src/modules/access/access-control.interface.ts +8 -7
  188. package/src/modules/access/access-control.service.ts +7 -7
  189. package/src/modules/access/access-declarations.ts +5 -5
  190. package/src/modules/access/access-mutation.service.ts +3 -3
  191. package/src/modules/access/access-splice.ts +4 -4
  192. package/src/modules/access/access.routes.ts +23 -20
  193. package/src/modules/access/creator-access.ts +5 -5
  194. package/src/modules/access/render-roles-yaml.ts +65 -0
  195. package/src/modules/access/roles-admin.service.ts +45 -16
  196. package/src/modules/admin/admin-access.routes.ts +29 -29
  197. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  198. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  199. package/src/modules/auth/rate-limit.ts +45 -45
  200. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  201. package/src/modules/code-mode/code-mode-names.ts +10 -36
  202. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  203. package/src/modules/database/connection.ts +15 -15
  204. package/src/modules/database/schema.ts +11 -11
  205. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  206. package/src/modules/mcp/mcp.service.ts +57 -435
  207. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  208. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  209. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  210. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  211. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  212. package/src/modules/plugins/index.ts +17 -0
  213. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  214. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  215. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  216. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  217. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  218. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  219. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  220. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  221. package/src/modules/settings/setup.routes.ts +112 -5
  222. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  223. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  224. package/src/modules/skills/pending-skills.service.ts +7 -7
  225. package/src/modules/skills/skills.contract.ts +4 -4
  226. package/src/modules/skills/skills.service.ts +5 -5
  227. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  228. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  229. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  230. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  231. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  232. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  233. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  234. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  235. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  236. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  237. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  238. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  239. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  240. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  241. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  242. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  243. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  244. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  245. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  246. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  247. package/src/modules/workflow/git/git.service.ts +2 -2
  248. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  249. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  250. package/src/modules/workflow/workflow-hooks.ts +101 -101
  251. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  252. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  253. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  254. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  255. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  256. package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
  257. package/src/modules/workspace/bevel-ignore.ts +66 -66
  258. package/src/modules/workspace/session-sink.ts +25 -25
  259. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  260. package/src/modules/workspace/startup/kb-git.ts +94 -0
  261. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  262. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  263. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  264. package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +561 -0
  265. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  266. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  267. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  268. package/src/modules/workspace/workspace.routes.ts +3 -3
  269. package/src/modules/workspace/workspace.service.ts +2 -35
  270. package/src/modules/workspace/workspace.tools.ts +922 -922
  271. package/src/shared/__tests__/join-request.test.ts +13 -13
  272. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  273. package/src/shared/kb-layout.test.ts +3 -3
  274. package/src/shared/utcp-namespace.ts +10 -68
  275. package/src/shared/variable-refs.ts +64 -0
  276. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  277. package/src/modules/groups/index.ts +0 -17
  278. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
  279. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  280. package/src/modules/workspace/kb-seed.service.ts +0 -555
  281. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  282. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it, vi, afterEach } from 'vitest';
2
+ import { descriptorsFromMcpJson } from '../mcp-json-discovery.js';
3
+
4
+ afterEach(() => vi.restoreAllMocks());
5
+
6
+ const MANIFEST = JSON.stringify({
7
+ name: 'gtm',
8
+ extensions: {
9
+ 'software.bevel.hexis': {
10
+ mcpServers: {
11
+ vendor: {
12
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}' },
13
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
14
+ description: 'Vendor API',
15
+ },
16
+ localbox: { local: true },
17
+ },
18
+ },
19
+ },
20
+ });
21
+
22
+ describe('descriptorsFromMcpJson', () => {
23
+ it('synthesizes descriptors keyed by server name, path at mcp.json', () => {
24
+ const out = descriptorsFromMcpJson(
25
+ 'GTM',
26
+ JSON.stringify({ mcpServers: { notion: { type: 'streamable-http', url: 'https://mcp.notion.com/mcp' } } }),
27
+ null,
28
+ );
29
+ expect(out).toEqual([
30
+ {
31
+ slug: 'notion',
32
+ name: 'notion',
33
+ path: 'Plugins/GTM/mcp.json',
34
+ type: 'mcp',
35
+ url: 'https://mcp.notion.com/mcp',
36
+ },
37
+ ]);
38
+ });
39
+
40
+ it('merges extension auth over mcp.json literals and carries variables + description', () => {
41
+ const out = descriptorsFromMcpJson(
42
+ 'GTM',
43
+ JSON.stringify({
44
+ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp', headers: { 'X-V': '2' } } },
45
+ }),
46
+ MANIFEST,
47
+ );
48
+ expect(out[0]).toMatchObject({
49
+ headers: { 'X-V': '2', Authorization: 'Bearer ${VENDOR_KEY}' },
50
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
51
+ description: 'Vendor API',
52
+ });
53
+ });
54
+
55
+ it('marks extension-flagged servers and every stdio server local-only', () => {
56
+ const out = descriptorsFromMcpJson(
57
+ 'GTM',
58
+ JSON.stringify({
59
+ mcpServers: {
60
+ localbox: { type: 'streamable-http', url: 'http://localhost:9000/mcp' },
61
+ indexer: { type: 'stdio', command: 'npx', args: ['-y', 'indexer'] },
62
+ },
63
+ }),
64
+ MANIFEST,
65
+ );
66
+ expect(out.find((d) => d.name === 'localbox')?.remote).toBe(false);
67
+ const stdio = out.find((d) => d.name === 'indexer');
68
+ expect(stdio?.remote).toBe(false);
69
+ expect(stdio?.stdio).toEqual({ command: 'npx', args: ['-y', 'indexer'], env: undefined, cwd: undefined });
70
+ });
71
+
72
+ it('skips a malformed entry without losing its siblings', () => {
73
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
74
+ const out = descriptorsFromMcpJson(
75
+ 'GTM',
76
+ JSON.stringify({
77
+ mcpServers: {
78
+ 'Bad Name!': { type: 'streamable-http', url: 'https://x.example' },
79
+ nourl: { type: 'streamable-http' },
80
+ odd: { type: 'carrier-pigeon' },
81
+ ok: { type: 'streamable-http', url: 'https://ok.example/mcp' },
82
+ },
83
+ }),
84
+ null,
85
+ );
86
+ expect(out.map((d) => d.name)).toEqual(['ok']);
87
+ });
88
+
89
+ it('ignores malformed extension headers instead of spreading them into keys', () => {
90
+ // A string spread into the merge would scatter its indices ('0', '1', …)
91
+ // into header names; a malformed extension must cost its own data only.
92
+ const out = descriptorsFromMcpJson(
93
+ 'GTM',
94
+ JSON.stringify({
95
+ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp', headers: { 'X-V': '2' } } },
96
+ }),
97
+ JSON.stringify({
98
+ name: 'gtm',
99
+ extensions: { 'software.bevel.hexis': { mcpServers: { vendor: { headers: 'Bearer oops' } } } },
100
+ }),
101
+ );
102
+ expect(out).toHaveLength(1);
103
+ expect(out[0].headers).toEqual({ 'X-V': '2' });
104
+ });
105
+
106
+ it('skips a server whose variables declaration is malformed, keeping siblings', () => {
107
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
108
+ // A dropped declaration would silently re-scope a credential (undeclared
109
+ // defaults to the shared admin row), so a bad entry takes the SERVER
110
+ // offline — never just the entry, and never its siblings.
111
+ const out = descriptorsFromMcpJson(
112
+ 'GTM',
113
+ JSON.stringify({
114
+ mcpServers: {
115
+ vendor: { type: 'streamable-http', url: 'https://v.example/mcp' },
116
+ ok: { type: 'streamable-http', url: 'https://ok.example/mcp' },
117
+ },
118
+ }),
119
+ JSON.stringify({
120
+ name: 'gtm',
121
+ extensions: {
122
+ 'software.bevel.hexis': {
123
+ mcpServers: { vendor: { variables: [{ name: 'bad name!', scope: 'user' }] } },
124
+ },
125
+ },
126
+ }),
127
+ );
128
+ expect(out.map((d) => d.name)).toEqual(['ok']);
129
+ });
130
+
131
+ it('applies the .tool declaration rules: no reserved names, no duplicates, gated OAuth URLs, string authParams', () => {
132
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
133
+ const declare = (variables: unknown) =>
134
+ descriptorsFromMcpJson(
135
+ 'GTM',
136
+ JSON.stringify({ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp' } } }),
137
+ JSON.stringify({
138
+ name: 'gtm',
139
+ extensions: { 'software.bevel.hexis': { mcpServers: { vendor: { variables } } } },
140
+ }),
141
+ ).map((d) => d.name);
142
+ // A re-declared platform-seeded name would shadow the seeding; a
143
+ // duplicate makes scope resolution order-dependent.
144
+ expect(declare([{ name: 'CONNECTION_KEY', scope: 'user' }])).toEqual([]);
145
+ expect(declare([{ name: 'K', scope: 'user' }, { name: 'K', scope: 'admin' }])).toEqual([]);
146
+ const oauth = { tokenUrl: 'https://v.example/token', clientId: 'c' };
147
+ // Same https + SSRF gate the .tool parser runs on OAuth endpoints.
148
+ expect(declare([{ name: 'T', scope: 'user', oauth: { ...oauth, authorizationUrl: 'http://v.example/auth' } }])).toEqual([]);
149
+ expect(declare([{ name: 'T', scope: 'user', oauth: { ...oauth, authorizationUrl: 'https://169.254.169.254/auth' } }])).toEqual([]);
150
+ // authParams travel verbatim as query params — non-strings are malformed.
151
+ expect(
152
+ declare([{ name: 'T', scope: 'user', oauth: { ...oauth, authorizationUrl: 'https://v.example/auth', authParams: { p: 1 } } }]),
153
+ ).toEqual([]);
154
+ expect(
155
+ declare([{ name: 'T', scope: 'user', oauth: { ...oauth, authorizationUrl: 'https://v.example/auth', authParams: { p: 'x' } } }]),
156
+ ).toEqual(['vendor']);
157
+ // `clientId` must be non-empty after trimming, as in the `.tool` parser:
158
+ // a whitespace-only value would pass discovery only to fail the owner's
159
+ // client-secret setup later with "clientId is required".
160
+ expect(
161
+ declare([{ name: 'T', scope: 'user', oauth: { tokenUrl: 'https://v.example/token', authorizationUrl: 'https://v.example/auth', clientId: ' ' } }]),
162
+ ).toEqual([]);
163
+ });
164
+
165
+ it('trims a padded oauth clientId like the .tool parser does', () => {
166
+ const out = descriptorsFromMcpJson(
167
+ 'GTM',
168
+ JSON.stringify({ mcpServers: { vendor: { type: 'streamable-http', url: 'https://v.example/mcp' } } }),
169
+ JSON.stringify({
170
+ name: 'gtm',
171
+ extensions: {
172
+ 'software.bevel.hexis': {
173
+ mcpServers: {
174
+ vendor: {
175
+ variables: [
176
+ {
177
+ name: 'T',
178
+ scope: 'user',
179
+ oauth: { authorizationUrl: 'https://v.example/auth', tokenUrl: 'https://v.example/token', clientId: ' c-1 ' },
180
+ },
181
+ ],
182
+ },
183
+ },
184
+ },
185
+ },
186
+ }),
187
+ );
188
+ expect(out[0]?.variables?.[0]?.oauth?.clientId).toBe('c-1');
189
+ });
190
+
191
+ it('yields nothing for an unparsable file, quietly for an absent extensions block', () => {
192
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
193
+ expect(descriptorsFromMcpJson('GTM', '{ not json', null)).toEqual([]);
194
+ expect(
195
+ descriptorsFromMcpJson('GTM', JSON.stringify({ mcpServers: {} }), '{ also not json'),
196
+ ).toEqual([]);
197
+ });
198
+ });
@@ -0,0 +1,346 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { DEFAULT_BRANCH, type AuthUser } from '@bevel-software/platform-shared';
6
+ import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import type { IAccessControl } from '../../access/access-control.interface.js';
9
+ import type { IToolManualService, ToolManualSummary } from '../tool-manuals.contract.js';
10
+ import { McpServerEditService } from '../mcp-server-edit.service.js';
11
+
12
+ const KB = 'knowledge-base';
13
+ const USER: AuthUser = { id: 'u-1', email: 'ali@example.com', name: 'Ali' } as AuthUser;
14
+
15
+ let root: string;
16
+ let repo: string;
17
+ let svc: McpServerEditService;
18
+ let commits: { runPendingCommit: ReturnType<typeof vi.fn> };
19
+ let canWrite: boolean;
20
+ let invalidated: number;
21
+
22
+ /** Summaries the locate step reads — mirrors what mcp.json discovery yields. */
23
+ const summaries: ToolManualSummary[] = [
24
+ { slug: 'vendor', name: 'vendor', path: 'Plugins/GTM/mcp.json', type: 'mcp' },
25
+ { slug: 'legacy', name: 'legacy', path: 'Plugins/GTM/software.bevel.hexis/tools/legacy.tool', type: 'mcp' },
26
+ ];
27
+
28
+ async function write(rel: string, content: unknown): Promise<void> {
29
+ const abs = path.join(repo, rel);
30
+ await fs.mkdir(path.dirname(abs), { recursive: true });
31
+ await fs.writeFile(abs, JSON.stringify(content, null, 2), 'utf-8');
32
+ }
33
+
34
+ async function readJson(rel: string): Promise<Record<string, unknown>> {
35
+ return JSON.parse(await fs.readFile(path.join(repo, rel), 'utf-8'));
36
+ }
37
+
38
+ beforeEach(async () => {
39
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-mcp-edit-'));
40
+ const wsDir = path.join(root, workspaceIdForBranch(DEFAULT_BRANCH));
41
+ repo = path.join(wsDir, KB);
42
+ canWrite = true;
43
+ invalidated = 0;
44
+ commits = { runPendingCommit: vi.fn(async () => undefined) };
45
+
46
+ await write('Plugins/GTM/mcp.json', {
47
+ mcpServers: {
48
+ vendor: { type: 'streamable-http', url: 'https://v.example/mcp', headers: { 'X-V': '2' } },
49
+ },
50
+ });
51
+ await write('Plugins/GTM/plugin.json', {
52
+ name: 'gtm',
53
+ extensions: {
54
+ 'software.bevel.hexis': {
55
+ mcpServers: {
56
+ vendor: {
57
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}' },
58
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
59
+ },
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ const workspaceService = {
66
+ getOrCreateForBranch: vi.fn(async () => ({ id: workspaceIdForBranch(DEFAULT_BRANCH) })),
67
+ getWorkspacePath: vi.fn(async () => wsDir),
68
+ } as unknown as WorkspaceService;
69
+ const accessControl = {
70
+ canWrite: vi.fn(async () => canWrite),
71
+ } as unknown as IAccessControl;
72
+ const toolManuals = {
73
+ listAccessible: vi.fn(async () => summaries),
74
+ invalidate: vi.fn(() => {
75
+ invalidated += 1;
76
+ }),
77
+ } as unknown as IToolManualService;
78
+
79
+ svc = new McpServerEditService(workspaceService, commits, accessControl, toolManuals, KB);
80
+ });
81
+
82
+ afterEach(async () => {
83
+ await fs.rm(root, { recursive: true, force: true });
84
+ vi.restoreAllMocks();
85
+ });
86
+
87
+ describe('getServer', () => {
88
+ it('merges both files into one view', async () => {
89
+ const view = await svc.getServer(USER.email, 'vendor');
90
+ expect(view).toMatchObject({
91
+ name: 'vendor',
92
+ transport: 'streamable-http',
93
+ url: 'https://v.example/mcp',
94
+ literalHeaders: { 'X-V': '2' },
95
+ authHeaders: { Authorization: 'Bearer ${VENDOR_KEY}' },
96
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
97
+ local: false,
98
+ canWrite: true,
99
+ });
100
+ });
101
+
102
+ it('shape-checks hand-editable fields — malformed values read as empty, not as nonsense', async () => {
103
+ await write('Plugins/GTM/plugin.json', {
104
+ name: 'gtm',
105
+ extensions: {
106
+ 'software.bevel.hexis': {
107
+ // A string where an array/object belongs — the form would `.map`
108
+ // and `Object.entries` these; they must arrive as empty instead.
109
+ mcpServers: { vendor: { headers: 'oops', variables: 'not-an-array' } },
110
+ },
111
+ },
112
+ });
113
+ const view = await svc.getServer(USER.email, 'vendor');
114
+ expect(view?.variables).toEqual([]);
115
+ expect(view?.authHeaders).toEqual({});
116
+ });
117
+
118
+ it('answers null for an unknown slug and for a legacy .tool-backed manual alike', async () => {
119
+ expect(await svc.getServer(USER.email, 'nope')).toBeNull();
120
+ // A `.tool`-backed mcp manual predates the authoritative file; the form
121
+ // has no pair of files to edit for it, so it is not editable here.
122
+ expect(await svc.getServer(USER.email, 'legacy')).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe('putServer', () => {
127
+ it('rewrites both files and commits the folder once', async () => {
128
+ await svc.putServer(USER, 'vendor', {
129
+ transport: 'streamable-http',
130
+ url: 'https://v2.example/mcp',
131
+ literalHeaders: { 'X-V': '3' },
132
+ authHeaders: { Authorization: 'Bearer ${VENDOR_KEY}' },
133
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
134
+ });
135
+ const mcp = await readJson('Plugins/GTM/mcp.json');
136
+ expect((mcp.mcpServers as Record<string, unknown>).vendor).toEqual({
137
+ type: 'streamable-http',
138
+ url: 'https://v2.example/mcp',
139
+ headers: { 'X-V': '3' },
140
+ });
141
+ expect(commits.runPendingCommit).toHaveBeenCalledTimes(1);
142
+ expect(commits.runPendingCommit).toHaveBeenCalledWith(
143
+ workspaceIdForBranch(DEFAULT_BRANCH),
144
+ DEFAULT_BRANCH,
145
+ `${KB}/Plugins/GTM`,
146
+ USER,
147
+ );
148
+ expect(invalidated).toBe(1);
149
+ });
150
+
151
+ it('reroutes a ${VAR} that arrives in the literal headers — mcp.json never carries one', async () => {
152
+ await svc.putServer(USER, 'vendor', {
153
+ transport: 'streamable-http',
154
+ url: 'https://v.example/mcp',
155
+ literalHeaders: { 'X-Key': '${SNEAKY}' },
156
+ });
157
+ const mcp = await readJson('Plugins/GTM/mcp.json');
158
+ expect((mcp.mcpServers as Record<string, { headers?: unknown }>).vendor.headers).toBeUndefined();
159
+ const manifest = await readJson('Plugins/GTM/plugin.json');
160
+ const ext = (manifest.extensions as Record<string, { mcpServers: Record<string, { headers?: unknown }> }>)[
161
+ 'software.bevel.hexis'
162
+ ];
163
+ // The rerouted header lands BESIDE the stored auth (omitted, so kept) —
164
+ // rerouting must add to the extensions half, not replace it.
165
+ expect(ext.mcpServers.vendor.headers).toEqual({
166
+ Authorization: 'Bearer ${VENDOR_KEY}',
167
+ 'X-Key': '${SNEAKY}',
168
+ });
169
+ });
170
+
171
+ it('renames across BOTH files, carrying every omitted field to the new key', async () => {
172
+ await svc.putServer(USER, 'vendor', {
173
+ newName: 'vendor_eu',
174
+ transport: 'streamable-http',
175
+ url: 'https://v.example/mcp',
176
+ authHeaders: { Authorization: 'Bearer ${VENDOR_KEY}' },
177
+ });
178
+ // FULL file contents, not just the key set: a rename that dropped the
179
+ // literal header or the variable declaration would still move the key.
180
+ const mcp = await readJson('Plugins/GTM/mcp.json');
181
+ expect(mcp.mcpServers).toEqual({
182
+ vendor_eu: {
183
+ type: 'streamable-http',
184
+ url: 'https://v.example/mcp',
185
+ headers: { 'X-V': '2' },
186
+ },
187
+ });
188
+ const manifest = await readJson('Plugins/GTM/plugin.json');
189
+ const servers = (
190
+ manifest.extensions as Record<string, { mcpServers: Record<string, unknown> }>
191
+ )['software.bevel.hexis'].mcpServers;
192
+ expect(servers).toEqual({
193
+ vendor_eu: {
194
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}' },
195
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
196
+ },
197
+ });
198
+ });
199
+
200
+ it('preserves fields the write does not surface, and clears on explicit empties', async () => {
201
+ // Nothing surfaced beyond the transport: everything stored survives.
202
+ await svc.putServer(USER, 'vendor', { transport: 'streamable-http' });
203
+ let mcp = await readJson('Plugins/GTM/mcp.json');
204
+ expect((mcp.mcpServers as Record<string, unknown>).vendor).toEqual({
205
+ type: 'streamable-http',
206
+ url: 'https://v.example/mcp',
207
+ headers: { 'X-V': '2' },
208
+ });
209
+ let manifest = await readJson('Plugins/GTM/plugin.json');
210
+ let servers = (
211
+ manifest.extensions as Record<string, { mcpServers: Record<string, unknown> }>
212
+ )['software.bevel.hexis'].mcpServers;
213
+ expect(servers.vendor).toEqual({
214
+ headers: { Authorization: 'Bearer ${VENDOR_KEY}' },
215
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
216
+ });
217
+
218
+ // Present-but-empty is an explicit CLEAR, not an omission.
219
+ await svc.putServer(USER, 'vendor', {
220
+ transport: 'streamable-http',
221
+ literalHeaders: {},
222
+ authHeaders: {},
223
+ variables: [],
224
+ });
225
+ mcp = await readJson('Plugins/GTM/mcp.json');
226
+ expect((mcp.mcpServers as Record<string, unknown>).vendor).toEqual({
227
+ type: 'streamable-http',
228
+ url: 'https://v.example/mcp',
229
+ });
230
+ manifest = await readJson('Plugins/GTM/plugin.json');
231
+ servers = (
232
+ manifest.extensions as Record<string, { mcpServers: Record<string, unknown> }>
233
+ )['software.bevel.hexis'].mcpServers;
234
+ // An emptied extension entry is REMOVED, not left as `{}`.
235
+ expect(servers.vendor).toBeUndefined();
236
+ });
237
+
238
+ it('refuses a rename onto a taken key with 409, committing nothing', async () => {
239
+ // A second server occupies the target name.
240
+ const mcp = await readJson('Plugins/GTM/mcp.json');
241
+ (mcp.mcpServers as Record<string, unknown>).vendor_eu = {
242
+ type: 'streamable-http',
243
+ url: 'https://eu.example/mcp',
244
+ };
245
+ await fs.writeFile(path.join(repo, 'Plugins/GTM/mcp.json'), JSON.stringify(mcp), 'utf-8');
246
+ await expect(
247
+ svc.putServer(USER, 'vendor', { newName: 'vendor_eu', transport: 'streamable-http', url: 'https://v.example/mcp' }),
248
+ ).rejects.toMatchObject({ status: 409 });
249
+ expect(commits.runPendingCommit).not.toHaveBeenCalled();
250
+ // The original key survives untouched.
251
+ const after = await readJson('Plugins/GTM/mcp.json');
252
+ expect(Object.keys(after.mcpServers as object).sort()).toEqual(['vendor', 'vendor_eu']);
253
+ });
254
+
255
+ it('refuses without write access, an invalid name, and a bad URL', async () => {
256
+ canWrite = false;
257
+ await expect(
258
+ svc.putServer(USER, 'vendor', { transport: 'streamable-http', url: 'https://v.example' }),
259
+ ).rejects.toMatchObject({ status: 403 });
260
+ canWrite = true;
261
+ await expect(
262
+ svc.putServer(USER, 'vendor', { newName: 'Bad Name', transport: 'streamable-http', url: 'https://v.example' }),
263
+ ).rejects.toMatchObject({ status: 422 });
264
+ await expect(
265
+ svc.putServer(USER, 'vendor', { transport: 'streamable-http', url: 'not-a-url' }),
266
+ ).rejects.toMatchObject({ status: 422 });
267
+ expect(commits.runPendingCommit).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('422s a malformed variables declaration instead of persisting a server discovery will drop', async () => {
271
+ // Discovery's own validator, at save time: each of these shapes would
272
+ // save fine and then make the whole server vanish from the catalog on
273
+ // the next scan.
274
+ const bad: unknown[] = [
275
+ [{ name: 'bad name!', scope: 'user' }], // name outside [A-Za-z0-9_]+
276
+ [{ name: 'API_URL', scope: 'admin' }], // platform-seeded name re-declared
277
+ [{ name: 'K', scope: 'user' }, { name: 'K', scope: 'admin' }], // duplicate
278
+ [{ name: 'K', scope: 'team' }], // unknown scope
279
+ [
280
+ {
281
+ name: 'K',
282
+ scope: 'admin', // oauth demands per-caller scope
283
+ oauth: { authorizationUrl: 'https://v.example/auth', tokenUrl: 'https://v.example/token', clientId: 'c' },
284
+ },
285
+ ],
286
+ [
287
+ {
288
+ name: 'K',
289
+ scope: 'user', // whitespace-only clientId — same rule as discovery
290
+ oauth: { authorizationUrl: 'https://v.example/auth', tokenUrl: 'https://v.example/token', clientId: ' ' },
291
+ },
292
+ ],
293
+ ];
294
+ const before = await readJson('Plugins/GTM/plugin.json');
295
+ for (const variables of bad) {
296
+ await expect(
297
+ svc.putServer(USER, 'vendor', {
298
+ transport: 'streamable-http',
299
+ url: 'https://v.example/mcp',
300
+ variables: variables as never,
301
+ }),
302
+ ).rejects.toMatchObject({ status: 422 });
303
+ }
304
+ // Nothing landed: no commit, no file change.
305
+ expect(commits.runPendingCommit).not.toHaveBeenCalled();
306
+ expect(await readJson('Plugins/GTM/plugin.json')).toEqual(before);
307
+ });
308
+
309
+ it('422s malformed STORED variables on a save that never touched them — not a silent clear', async () => {
310
+ // A hand-edited plugin.json whose declarations went bad: coercing them
311
+ // to [] before validation would let any unrelated save erase them.
312
+ await write('Plugins/GTM/plugin.json', {
313
+ name: 'gtm',
314
+ extensions: {
315
+ 'software.bevel.hexis': { mcpServers: { vendor: { variables: 'corrupt' } } },
316
+ },
317
+ });
318
+ const before = await readJson('Plugins/GTM/plugin.json');
319
+ await expect(
320
+ svc.putServer(USER, 'vendor', { transport: 'streamable-http', url: 'https://v.example/mcp' }),
321
+ ).rejects.toMatchObject({ status: 422 });
322
+ expect(commits.runPendingCommit).not.toHaveBeenCalled();
323
+ expect(await readJson('Plugins/GTM/plugin.json')).toEqual(before);
324
+ // Supplying valid declarations IS the repair path — that save lands.
325
+ await svc.putServer(USER, 'vendor', {
326
+ transport: 'streamable-http',
327
+ url: 'https://v.example/mcp',
328
+ variables: [{ name: 'VENDOR_KEY', scope: 'user' }],
329
+ });
330
+ expect(commits.runPendingCommit).toHaveBeenCalledTimes(1);
331
+ });
332
+
333
+ it('writes a stdio entry with command/args and no url', async () => {
334
+ await svc.putServer(USER, 'vendor', {
335
+ transport: 'stdio',
336
+ command: 'npx',
337
+ args: ['-y', 'indexer'],
338
+ });
339
+ const mcp = await readJson('Plugins/GTM/mcp.json');
340
+ expect((mcp.mcpServers as Record<string, unknown>).vendor).toEqual({
341
+ type: 'stdio',
342
+ command: 'npx',
343
+ args: ['-y', 'indexer'],
344
+ });
345
+ });
346
+ });
@@ -0,0 +1,101 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import express from 'express';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
8
+ import { createToolManualsAgentRoutes } from '../tool-manuals.routes.js';
9
+ import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
10
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
11
+ import type { IAccessControl } from '../../access/access-control.interface.js';
12
+ import type { IToolManualService } from '../tool-manuals.contract.js';
13
+
14
+ /**
15
+ * `GET /agent/plugins/:folder/archive` — error taxonomy of the plugin-folder
16
+ * probe. An ABSENT folder is a 404; a folder the process cannot lstat
17
+ * (EACCES, EIO) is a real failure that must surface as a 500, because a 404
18
+ * would report a permission problem as "this plugin does not exist" and the
19
+ * caller would delete its local copy of a plugin that is still there.
20
+ */
21
+
22
+ const KB = 'knowledge-base';
23
+
24
+ let httpServer: HttpServer | undefined;
25
+ let root: string;
26
+ let wsDir: string;
27
+
28
+ async function baseUrl(): Promise<string> {
29
+ const app = express();
30
+ const manualAuth: express.RequestHandler = (req, _res, next) => {
31
+ req.toolAuth = { userId: 'u-1' } as never;
32
+ next();
33
+ };
34
+ const workspaceService = {
35
+ getOrCreateForBranch: vi.fn(async () => ({ id: workspaceIdForBranch(DEFAULT_BRANCH) })),
36
+ getWorkspacePath: vi.fn(async () => wsDir),
37
+ } as unknown as WorkspaceService;
38
+ const accessControl = {
39
+ canReadBatch: vi.fn(async (_ws: string, _email: string, rels: string[]) => {
40
+ return new Map(rels.map((r) => [r, true]));
41
+ }),
42
+ } as unknown as IAccessControl;
43
+ app.use(
44
+ '/api',
45
+ createToolManualsAgentRoutes(
46
+ {} as unknown as IToolManualService,
47
+ manualAuth,
48
+ async () => 'ali@example.com',
49
+ { workspaceService, accessControl, kbDirName: KB },
50
+ ),
51
+ );
52
+ httpServer = await new Promise<HttpServer>((resolve) => {
53
+ const s = app.listen(0, () => resolve(s));
54
+ });
55
+ const port = (httpServer.address() as { port: number }).port;
56
+ return `http://127.0.0.1:${port}`;
57
+ }
58
+
59
+ beforeEach(async () => {
60
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-archive-'));
61
+ wsDir = path.join(root, workspaceIdForBranch(DEFAULT_BRANCH));
62
+ await fs.mkdir(wsDir, { recursive: true });
63
+ });
64
+
65
+ afterEach(async () => {
66
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
67
+ httpServer = undefined;
68
+ vi.restoreAllMocks();
69
+ await fs.rm(root, { recursive: true, force: true });
70
+ });
71
+
72
+ describe('GET /agent/plugins/:folder/archive', () => {
73
+ it('zips a readable plugin folder', async () => {
74
+ const pluginDir = path.join(wsDir, KB, 'Plugins', 'GTM');
75
+ await fs.mkdir(pluginDir, { recursive: true });
76
+ await fs.writeFile(path.join(pluginDir, 'web-search.tool'), '{"name":"web_search"}', 'utf-8');
77
+ const base = await baseUrl();
78
+ const res = await fetch(`${base}/api/agent/plugins/GTM/archive`);
79
+ expect(res.status).toBe(200);
80
+ expect(res.headers.get('content-type')).toContain('application/zip');
81
+ });
82
+
83
+ it('404s an absent plugin folder — ENOENT is an absence', async () => {
84
+ const base = await baseUrl();
85
+ const res = await fetch(`${base}/api/agent/plugins/Nope/archive`);
86
+ expect(res.status).toBe(404);
87
+ expect(await res.json()).toEqual({ error: 'Not found' });
88
+ });
89
+
90
+ it('500s when the folder probe fails for a non-ENOENT reason', async () => {
91
+ vi.spyOn(console, 'error').mockImplementation(() => {});
92
+ const denied: NodeJS.ErrnoException = Object.assign(new Error('EACCES: permission denied'), {
93
+ code: 'EACCES',
94
+ });
95
+ vi.spyOn(fs, 'lstat').mockRejectedValueOnce(denied);
96
+ const base = await baseUrl();
97
+ const res = await fetch(`${base}/api/agent/plugins/GTM/archive`);
98
+ expect(res.status).toBe(500);
99
+ expect(await res.json()).toEqual({ error: 'Failed to archive plugin' });
100
+ });
101
+ });
@@ -24,7 +24,7 @@ import type { IToolManualService, ToolManualDetail } from '../tool-manuals.contr
24
24
  const DETAIL: ToolManualDetail = {
25
25
  slug: 'github',
26
26
  name: 'github',
27
- path: 'Groups/Engineering/github.tool',
27
+ path: 'Plugins/Engineering/github.tool',
28
28
  type: 'inline',
29
29
  description: 'Read and write GitHub issues and PRs.',
30
30
  capabilities: [{ name: 'create_issue', description: 'Open an issue in a repo.' }],
@@ -165,7 +165,7 @@ describe('ToolManualService.getDetail — capabilities + access', () => {
165
165
  /** Write one `.tool` into a fresh temp KB and return the service over it. */
166
166
  async function withTool(file: string, content: string): Promise<void> {
167
167
  root = await mkdtemp(join(tmpdir(), 'tooldetail-'));
168
- const tools = join(root, wsId, KB_DIR, 'Groups');
168
+ const tools = join(root, wsId, KB_DIR, 'Plugins');
169
169
  await mkdir(tools, { recursive: true });
170
170
  await writeFile(join(tools, file), content);
171
171
  }
@@ -189,7 +189,7 @@ describe('ToolManualService.getDetail — capabilities + access', () => {
189
189
  expect(detail).toMatchObject({
190
190
  slug: 'github',
191
191
  name: 'github',
192
- path: 'Groups/github.tool',
192
+ path: 'Plugins/github.tool',
193
193
  type: 'inline',
194
194
  description: 'Read and write GitHub issues and PRs.',
195
195
  capabilities: [