@bevel-software/platform-core-backend 0.7.4 → 0.8.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 (246) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +38 -21
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
  104. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/pending-commits.service.js +37 -9
  106. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  107. package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
  108. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  109. package/dist/modules/workflow/pending-commits.worker.js +63 -9
  110. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  111. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  112. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  113. package/dist/modules/workflow/workflow.service.js +6 -1
  114. package/dist/modules/workflow/workflow.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/workspace.routes.js +3 -3
  124. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  125. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  126. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.service.js +83 -12
  128. package/dist/modules/workspace/workspace.service.js.map +1 -1
  129. package/dist/shared/kb-layout.test.js +3 -3
  130. package/dist/shared/kb-layout.test.js.map +1 -1
  131. package/dist/shared/utcp-namespace.d.ts +6 -27
  132. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  133. package/dist/shared/utcp-namespace.js +6 -63
  134. package/dist/shared/utcp-namespace.js.map +1 -1
  135. package/dist/shared/variable-refs.d.ts +42 -0
  136. package/dist/shared/variable-refs.d.ts.map +1 -0
  137. package/dist/shared/variable-refs.js +60 -0
  138. package/dist/shared/variable-refs.js.map +1 -0
  139. package/kb-template/.bevelignore +1 -1
  140. package/kb-template/AGENTS.md +88 -35
  141. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  142. package/kb-template/access.md +36 -36
  143. package/migrations/meta/0000_snapshot.json +1479 -1479
  144. package/package.json +5 -4
  145. package/src/assets.ts +25 -25
  146. package/src/core/core-ports.ts +106 -106
  147. package/src/core/create-core-server.ts +55 -9
  148. package/src/core/create-core-services.ts +48 -24
  149. package/src/index.ts +69 -69
  150. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  151. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  152. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  153. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  154. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  155. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  156. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  157. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  158. package/src/modules/access/access-control.interface.ts +8 -7
  159. package/src/modules/access/access-control.service.ts +7 -7
  160. package/src/modules/access/access-declarations.ts +5 -5
  161. package/src/modules/access/access-mutation.service.ts +3 -3
  162. package/src/modules/access/access-splice.ts +4 -4
  163. package/src/modules/access/access.routes.ts +20 -20
  164. package/src/modules/access/creator-access.ts +5 -5
  165. package/src/modules/access/roles-admin.service.ts +13 -2
  166. package/src/modules/admin/admin-access.routes.ts +29 -29
  167. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  168. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  169. package/src/modules/auth/rate-limit.ts +45 -45
  170. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  171. package/src/modules/code-mode/code-mode-names.ts +10 -36
  172. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  173. package/src/modules/database/connection.ts +15 -15
  174. package/src/modules/database/schema.ts +11 -11
  175. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  176. package/src/modules/mcp/mcp.service.ts +57 -435
  177. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  178. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  179. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  180. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  181. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  182. package/src/modules/plugins/index.ts +17 -0
  183. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  184. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  185. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  186. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  187. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  188. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  189. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  190. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  191. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  192. package/src/modules/skills/pending-skills.service.ts +7 -7
  193. package/src/modules/skills/skills.contract.ts +4 -4
  194. package/src/modules/skills/skills.service.ts +5 -5
  195. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  196. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  197. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  198. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  199. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  200. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  201. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  203. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  204. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  205. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  206. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  207. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  208. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  209. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +118 -0
  210. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  211. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  212. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  213. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  214. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  215. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  216. package/src/modules/workflow/git/git.service.ts +2 -2
  217. package/src/modules/workflow/pending-commits.service.ts +48 -12
  218. package/src/modules/workflow/pending-commits.worker.ts +64 -8
  219. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  220. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  221. package/src/modules/workflow/workflow-hooks.ts +101 -101
  222. package/src/modules/workflow/workflow.service.ts +13 -2
  223. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  224. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  225. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  226. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  227. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  228. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  229. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  230. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  231. package/src/modules/workspace/bevel-ignore.ts +66 -66
  232. package/src/modules/workspace/kb-seed.service.ts +38 -9
  233. package/src/modules/workspace/plugins-migration.ts +479 -0
  234. package/src/modules/workspace/session-sink.ts +25 -25
  235. package/src/modules/workspace/workspace.routes.ts +3 -3
  236. package/src/modules/workspace/workspace.service.ts +85 -14
  237. package/src/modules/workspace/workspace.tools.ts +922 -922
  238. package/src/shared/__tests__/join-request.test.ts +13 -13
  239. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  240. package/src/shared/kb-layout.test.ts +3 -3
  241. package/src/shared/utcp-namespace.ts +10 -68
  242. package/src/shared/variable-refs.ts +64 -0
  243. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  244. package/src/modules/groups/index.ts +0 -17
  245. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  246. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -1,7 +1,15 @@
1
1
  import express, { type Request, type RequestHandler } from 'express';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import AdmZip from 'adm-zip';
5
+ import { DEFAULT_BRANCH, PLUGINS_DIR } from '@bevel-software/platform-shared';
6
+ import { workspaceIdForBranch, type WorkspaceService } from '../workspace/workspace.service.js';
7
+ import type { IAccessControl } from '../access/access-control.interface.js';
2
8
  import '@utcp/http'; // side effect: register the 'http' call-template type
3
9
  import { CallTemplateSerializer, type CallTemplate } from '@utcp/sdk';
4
10
  import { type IToolManualService, EXTERNAL_KB_MANUAL_NAME } from './tool-manuals.contract.js';
11
+ import { McpServerEditError, type McpServerEditService, type McpServerWrite } from './mcp-server-edit.service.js';
12
+ import type { AuthUser } from '@bevel-software/platform-shared';
5
13
  import '../auth/auth.middleware.js'; // Express Request augmentation (req.userId / req.userEmail)
6
14
  import '../tool-auth/tool-auth.middleware.js'; // Express Request augmentation (req.toolAuth)
7
15
 
@@ -35,6 +43,7 @@ export function createToolManualsAgentRoutes(
35
43
  toolManualService: IToolManualService,
36
44
  manualAuth: RequestHandler,
37
45
  resolveUserEmail: ResolveUserEmail,
46
+ archiveDeps?: { workspaceService: WorkspaceService; accessControl: IAccessControl; kbDirName: string },
38
47
  ): express.Router {
39
48
  const router = express.Router();
40
49
 
@@ -63,6 +72,173 @@ export function createToolManualsAgentRoutes(
63
72
  }
64
73
  });
65
74
 
75
+ /**
76
+ * The whole plugin, byte-for-byte, as a zip — the materialization surface
77
+ * for the local MCP server. `read_file` is the agent's READING tool (text,
78
+ * one file at a time); this exists because a stdio server's plugin must
79
+ * land on the user's disk exactly as it is, binaries included. Access is
80
+ * per-file and the caller's own: every entry is filtered through the same
81
+ * read verdicts `list_files` uses, so a file the key cannot read is a file
82
+ * that is not in the archive — not an error, an absence.
83
+ */
84
+ router.get('/agent/plugins/:folder/archive', manualAuth, async (req, res) => {
85
+ if (!archiveDeps) return void res.status(404).json({ error: 'Not available' });
86
+ try {
87
+ const email = await callerEmail(req);
88
+ if (!email) return void res.status(403).json({ error: 'Forbidden' });
89
+ const folder = String(Array.isArray(req.params.folder) ? req.params.folder[0] : req.params.folder);
90
+ if (!folder || folder === '.' || folder === '..' || /[/\\]/.test(folder)) {
91
+ return void res.status(422).json({ error: 'Not a plugin folder name' });
92
+ }
93
+ const { workspaceService, accessControl, kbDirName } = archiveDeps;
94
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
95
+ await workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
96
+ const wsDir = await workspaceService.getWorkspacePath(wsId);
97
+ const pluginDir = path.join(wsDir, kbDirName, PLUGINS_DIR, folder);
98
+ // SYMLINKS ARE NOT SUPPORTED IN PLUGINS, anywhere. Access control
99
+ // resolves rules by path, and a symlink is a second path to the same
100
+ // content — a standing invitation for the spelling the ACL judged and
101
+ // the bytes served to diverge. The platform's own write paths never
102
+ // create one (they arrive only via direct git pushes), so every symlink
103
+ // is skipped rather than resolved: no target following, no cycle
104
+ // guards, no read-time re-resolution — complexity that existed only to
105
+ // support what the platform has no use for. Starting with the plugin
106
+ // folder itself: a symlinked `Plugins/<folder>` is not a plugin.
107
+ // Only ENOENT is an absence; an EACCES/EIO answered with 404 would
108
+ // dress a real read problem up as a missing plugin (the same contract
109
+ // as the realpath/open probes below).
110
+ const folderStat = await fs.lstat(pluginDir).catch((err: NodeJS.ErrnoException) => {
111
+ if (err.code === 'ENOENT') return null;
112
+ throw err;
113
+ });
114
+ if (folderStat === null || !folderStat.isDirectory()) {
115
+ return void res.status(404).json({ error: 'Not found' });
116
+ }
117
+ const rels: string[] = [];
118
+ const walk = async (dir: string, rel: string): Promise<void> => {
119
+ let entries;
120
+ try {
121
+ entries = await fs.readdir(dir, { withFileTypes: true });
122
+ } catch (err) {
123
+ // Only an absent directory is a non-event; anything else (EACCES,
124
+ // EIO) silently missing from the archive would hand the client an
125
+ // incomplete plugin stamped as success.
126
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
127
+ throw err;
128
+ }
129
+ for (const e of entries) {
130
+ if (e.name === '.git') continue;
131
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
132
+ // Dirent's isDirectory/isFile are both false for a symlink, so a
133
+ // link is never walked and never listed — but say so, once, because
134
+ // an author who committed one deserves to know it went nowhere.
135
+ if (e.isDirectory()) await walk(path.join(dir, e.name), childRel);
136
+ else if (e.isFile()) rels.push(childRel);
137
+ else if (e.isSymbolicLink()) {
138
+ console.warn(`[tool-manuals] archive of "${folder}": ${childRel} is a symlink — not supported in plugins, skipped.`);
139
+ }
140
+ }
141
+ };
142
+ await walk(pluginDir, '');
143
+ if (rels.length === 0) return void res.status(404).json({ error: 'Not found' });
144
+ const verdicts = await accessControl.canReadBatch(
145
+ wsId,
146
+ email,
147
+ rels.map((r) => `${PLUGINS_DIR}/${folder}/${r}`),
148
+ );
149
+ const zip = new AdmZip();
150
+ let included = 0;
151
+ let bytes = 0;
152
+ // The zip is built in memory; without a ceiling one plugin full of large
153
+ // assets is a backend OOM any key holder can trigger.
154
+ const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
155
+ // Canonical base for the read-time identity check below. The folder was
156
+ // lstat-verified a real directory above, so this is normalization
157
+ // (case, 8.3 names, drive spelling), not link resolution — but that
158
+ // verification is a moment old, and a plugin deleted since (a workspace
159
+ // reset, a merged deletion) is an ABSENCE, the same 404 it would have
160
+ // been a moment earlier, not an internal error.
161
+ const pluginRealBase = await fs.realpath(pluginDir).catch((err: NodeJS.ErrnoException) => {
162
+ if (err.code === 'ENOENT') return null;
163
+ throw err;
164
+ });
165
+ if (pluginRealBase === null) return void res.status(404).json({ error: 'Not found' });
166
+ for (const rel of rels) {
167
+ // Fail closed, per file — only an explicit `true` verdict is included.
168
+ if (verdicts.get(`${PLUGINS_DIR}/${folder}/${rel}`) !== true) continue;
169
+ const abs = path.join(pluginDir, ...rel.split('/'));
170
+ // The no-symlink rule, re-checked at read time over the WHOLE path.
171
+ // The open below guards only the final component — a PARENT directory
172
+ // swapped for a link between walk and read makes `abs` traverse the
173
+ // link with the final component still a regular file. realpath
174
+ // resolves every component, so demanding it equal the spelled path is
175
+ // exactly "no component is a link": identity, not mere containment.
176
+ const realNow = await fs.realpath(abs).catch((err: NodeJS.ErrnoException) => {
177
+ if (err.code === 'ENOENT') return null; // deleted since the walk — an absence, not a failure
178
+ throw err;
179
+ });
180
+ if (realNow === null || realNow !== path.join(pluginRealBase, ...rel.split('/'))) {
181
+ if (realNow !== null) {
182
+ console.warn(`[tool-manuals] archive of "${folder}": ${rel} no longer resolves to itself — skipped.`);
183
+ }
184
+ continue;
185
+ }
186
+ // Check and read through ONE file handle, so the bytes that land in
187
+ // the zip come from the very inode the checks passed — a path-based
188
+ // stat-then-readFile pair leaves a window where the path is swapped
189
+ // between the two and the archive ships bytes the ACL never judged.
190
+ // O_NOFOLLOW makes a final-component symlink fail the open itself
191
+ // where the platform defines it (Linux — production; Windows test
192
+ // runs fall back to the realpath identity check above alone). Only an
193
+ // ENOENT is a skip; any other failure is a real read problem that
194
+ // must surface as a 500, not ship as a silently partial archive.
195
+ const handle = await fs
196
+ .open(abs, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
197
+ .catch((err: NodeJS.ErrnoException) => {
198
+ if (err.code === 'ENOENT') return null; // deleted since the walk — an absence
199
+ if (err.code === 'ELOOP') {
200
+ // O_NOFOLLOW's spelling of "the final component is a symlink".
201
+ console.warn(`[tool-manuals] archive of "${folder}": ${rel} is a symlink — not supported in plugins, skipped.`);
202
+ return null;
203
+ }
204
+ throw err;
205
+ });
206
+ if (handle === null) continue;
207
+ try {
208
+ // fstat on the handle types and sizes the OPENED file — the size
209
+ // still gates BEFORE content, because rejecting after the read
210
+ // would already have spiked memory by exactly the payload the
211
+ // limit exists to refuse.
212
+ const stat = await handle.stat();
213
+ if (!stat.isFile()) continue;
214
+ bytes += stat.size;
215
+ if (bytes > MAX_ARCHIVE_BYTES) {
216
+ return void res.status(413).json({
217
+ error: `Plugin "${folder}" exceeds the ${MAX_ARCHIVE_BYTES / (1024 * 1024)}MB archive limit.`,
218
+ });
219
+ }
220
+ // Unix mode rides in the zip attrs so a `./`-command stdio server is
221
+ // still executable after materialization (0 on Windows — harmless).
222
+ // PLAIN mode, no shifting: adm-zip masks a numeric attr with 0xfff and
223
+ // positions it into the external-attribute high bits itself — a
224
+ // pre-shifted value is destroyed by that mask.
225
+ zip.addFile(rel, await handle.readFile(), '', stat.mode & 0o7777);
226
+ included += 1;
227
+ } finally {
228
+ await handle.close();
229
+ }
230
+ }
231
+ // An all-filtered plugin looks exactly like an absent one — a 404 must
232
+ // not confirm to a keyless caller that the folder exists.
233
+ if (included === 0) return void res.status(404).json({ error: 'Not found' });
234
+ res.setHeader('Content-Type', 'application/zip');
235
+ res.send(zip.toBuffer());
236
+ } catch (err) {
237
+ console.error('[tool-manuals] plugin archive failed:', err instanceof Error ? err.message : err);
238
+ res.status(500).json({ error: 'Failed to archive plugin' });
239
+ }
240
+ });
241
+
66
242
  router.get('/tools/:slug/manual', manualAuth, async (req, res) => {
67
243
  try {
68
244
  const email = await callerEmail(req);
@@ -89,9 +265,54 @@ export function createToolManualsAgentRoutes(
89
265
  * literal sibling is shadowed by the param segment;
90
266
  * `preview` is POST-only, so it never collides.
91
267
  */
92
- export function createToolManualsBrowserRoutes(toolManualService: IToolManualService): express.Router {
268
+ export function createToolManualsBrowserRoutes(
269
+ toolManualService: IToolManualService,
270
+ serverEdit?: { service: McpServerEditService; getUser: (userId: string) => Promise<AuthUser | undefined> },
271
+ ): express.Router {
93
272
  const router = express.Router();
94
273
 
274
+ /**
275
+ * Server-scoped read/write of one MCP server — the tool page's edit form.
276
+ * One server's truth spans mcp.json AND plugin.json's extensions block; a
277
+ * raw file editor shows a writer half of it at best, so the form talks to
278
+ * this pair instead. PUT commits both files together and refuses the states
279
+ * that would strand a half (see McpServerEditService).
280
+ */
281
+ router.get('/tools/:slug/server', async (req, res) => {
282
+ if (!serverEdit) return void res.status(404).json({ error: 'Not available' });
283
+ const email = req.userEmail;
284
+ if (!email) return void res.status(401).json({ error: 'Not authenticated' });
285
+ try {
286
+ const view = await serverEdit.service.getServer(email, String(req.params.slug));
287
+ if (!view) return void res.status(404).json({ error: 'Not found' });
288
+ res.json(view);
289
+ } catch (err) {
290
+ console.error('[tool-manuals] server read failed:', err instanceof Error ? err.message : err);
291
+ res.status(500).json({ error: 'Failed to read the server' });
292
+ }
293
+ });
294
+
295
+ router.put('/tools/:slug/server', async (req, res) => {
296
+ if (!serverEdit) return void res.status(404).json({ error: 'Not available' });
297
+ if (!req.userId) return void res.status(401).json({ error: 'Not authenticated' });
298
+ try {
299
+ const user = await serverEdit.getUser(req.userId);
300
+ if (!user) return void res.status(401).json({ error: 'Not authenticated' });
301
+ const result = await serverEdit.service.putServer(
302
+ user,
303
+ String(req.params.slug),
304
+ (req.body ?? {}) as McpServerWrite,
305
+ );
306
+ res.json(result);
307
+ } catch (err) {
308
+ if (err instanceof McpServerEditError) {
309
+ return void res.status(err.status).json({ error: err.message });
310
+ }
311
+ console.error('[tool-manuals] server write failed:', err instanceof Error ? err.message : err);
312
+ res.status(500).json({ error: 'Failed to save the server' });
313
+ }
314
+ });
315
+
95
316
  router.get('/tools', async (req, res) => {
96
317
  const email = req.userEmail;
97
318
  if (!email) return void res.status(401).json({ error: 'Not authenticated' });
@@ -9,11 +9,18 @@ import {
9
9
  DefaultVariableSubstitutor,
10
10
  type CallTemplate,
11
11
  } from '@utcp/sdk';
12
- import { DEFAULT_BRANCH, GROUPS_DIR } from '@bevel-software/platform-shared';
12
+ import {
13
+ DEFAULT_BRANCH,
14
+ PLUGINS_DIR,
15
+ PLUGIN_MANIFEST_FILE,
16
+ PLUGIN_MCP_FILE,
17
+ } from '@bevel-software/platform-shared';
18
+ import { descriptorsFromMcpJson } from './mcp-json-discovery.js';
13
19
  import type { WorkspaceService } from '../workspace/workspace.service.js';
14
20
  import { workspaceIdForBranch } from '../workspace/workspace.service.js';
15
21
  import type { IAccessControl } from '../access/access-control.interface.js';
16
22
  import { assertSafeFetchUrl } from '../../shared/ssrf.js';
23
+ import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/variable-refs.js';
17
24
  import { extractFrontmatter, resolveDeclaredId, isValidId, dedupeById } from '../../shared/frontmatter-id.js';
18
25
  import { walkFiles } from '../../shared/fs-walk.js';
19
26
  import { TtlCache } from '../../shared/ttl-cache.js';
@@ -27,6 +34,7 @@ import {
27
34
  EXTERNAL_KB_MANUAL_NAME,
28
35
  type IToolManualService,
29
36
  type ToolManualDescriptor,
37
+ type ToolManualDescriptorBase,
30
38
  type ToolManualSummary,
31
39
  type ToolManualDetail,
32
40
  type ToolCapability,
@@ -60,46 +68,26 @@ const MAX_CAPABILITIES = 100;
60
68
  const RESERVED_TOOL_NAMESPACES = [INTERNAL_MANUAL_NAME, EXTERNAL_KB_MANUAL_NAME].map((n) => n.toLowerCase());
61
69
 
62
70
  /**
63
- * Variable names the platform seeds for its own (Bevel-hosted) manuals:
64
- * `<ns>_API_URL` points a manual at the backend and `<ns>_CONNECTION_KEY`
65
- * carries the platform bearer. No user `.tool` may REFERENCE them
66
- * (`${API_URL}` / `$API_URL`), in any `.tool` type: the only seeded user
67
- * namespace is an inline `.tool`'s (its discovery template is platform-served),
68
- * and a reference inside author-written content would resolve platform creds
69
- * into a request the author shaped. Refusing every `.tool` at the producing
70
- * boundary makes "user tools never carry platform credentials" structural
71
- * rather than dependent on which namespaces happen to be seeded.
71
+ * No user `.tool` may REFERENCE the platform-seeded variables (`${API_URL}` /
72
+ * `$API_URL`), in any `.tool` type: the only seeded user namespace is an
73
+ * inline `.tool`'s (its discovery template is platform-served), and a
74
+ * reference inside author-written content would resolve platform creds into a
75
+ * request the author shaped. Refusing every `.tool` at the producing boundary
76
+ * makes "user tools never carry platform credentials" structural rather than
77
+ * dependent on which namespaces happen to be seeded. The names and the
78
+ * reference grammar live in `shared/variable-refs.ts` — one definition for
79
+ * every boundary that classifies references.
72
80
  */
73
- const RESERVED_VARIABLE_NAMES: readonly string[] = ['API_URL', 'CONNECTION_KEY'];
74
-
75
- /** The SDK substitutor's reference grammar, exactly: `${VAR}` or `$VAR`. */
76
- const VARIABLE_REFERENCE_RE = /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)/g;
77
-
78
- /**
79
- * A REFERENCE is reserved by SUFFIX, not by exact name: the substitutor looks a
80
- * variable up under the manual's UTCP namespace first, so `${<ns>_CONNECTION_KEY}`
81
- * resolves the very same seeded value the bare `${CONNECTION_KEY}` does. Matching
82
- * the bare name only would let a `.tool` reach the platform bearer just by
83
- * spelling the namespace out. Suffix matching also refuses harmless-looking
84
- * near-misses (`MY_API_URL`) — deliberately fail-closed: a `.tool` author who
85
- * wants their own base URL has the whole namespace minus two suffixes.
86
- */
87
- function isReservedVariableRef(varName: string): boolean {
88
- return RESERVED_VARIABLE_NAMES.some((reserved) => varName.endsWith(reserved));
89
- }
90
81
 
91
82
  /** Throw if any string in the `.tool` document references a reserved variable. */
92
83
  function assertNoReservedVariableRefs(doc: unknown, name: string): void {
93
- const text = JSON.stringify(doc) ?? '';
94
- for (const match of text.matchAll(VARIABLE_REFERENCE_RE)) {
95
- const varName = match[1] ?? match[2];
96
- if (isReservedVariableRef(varName)) {
97
- throw new Error(
98
- `\`.tool\` "${name}" references the reserved variable "${match[0]}" — ` +
99
- 'API_URL and CONNECTION_KEY (bare or namespaced, e.g. `<namespace>_CONNECTION_KEY`) ' +
100
- 'are seeded by the platform for its own manuals and may not appear anywhere in a `.tool`.',
101
- );
102
- }
84
+ const ref = findReservedVariableRef(doc);
85
+ if (ref !== null) {
86
+ throw new Error(
87
+ `\`.tool\` "${name}" references the reserved variable "${ref}" — ` +
88
+ 'API_URL and CONNECTION_KEY (bare or namespaced, e.g. `<namespace>_CONNECTION_KEY`) ' +
89
+ 'are seeded by the platform for its own manuals and may not appear anywhere in a `.tool`.',
90
+ );
103
91
  }
104
92
  }
105
93
 
@@ -270,7 +258,7 @@ export class ToolManualService implements IToolManualService {
270
258
  async preview(content: string): Promise<ToolManualPreview> {
271
259
  let descriptor: ToolManualDescriptor;
272
260
  try {
273
- descriptor = normalizeToolManual('draft', 'Groups/draft.tool', content);
261
+ descriptor = normalizeToolManual('draft', 'Plugins/draft.tool', content);
274
262
  } catch (err) {
275
263
  return { ok: false, errors: [err instanceof Error ? err.message : String(err)] };
276
264
  }
@@ -316,6 +304,28 @@ export class ToolManualService implements IToolManualService {
316
304
  headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
317
305
  };
318
306
  }
307
+ if (m.type === 'mcp' && m.stdio) {
308
+ // A stdio server, for LOCAL consumers only (`remote: false` is implied
309
+ // at discovery). Command/args/env/cwd pass through verbatim — the Agent
310
+ // Plugins placeholders (`${PLUGIN_ROOT}`/`${PLUGIN_DATA}`) are expanded
311
+ // by the LOCAL runtime against its materialized plugin copy; this
312
+ // process has no such paths and must not guess them.
313
+ return {
314
+ name: m.name,
315
+ call_template_type: 'mcp',
316
+ config: {
317
+ mcpServers: {
318
+ [m.name]: {
319
+ transport: 'stdio',
320
+ command: m.stdio.command,
321
+ args: m.stdio.args,
322
+ ...(m.stdio.env ? { env: m.stdio.env } : {}),
323
+ ...(m.stdio.cwd ? { cwd: m.stdio.cwd } : {}),
324
+ },
325
+ },
326
+ },
327
+ };
328
+ }
319
329
  if (m.type === 'mcp') {
320
330
  // Remote (HTTP/streamable) MCP server. Exact plugin field shape is
321
331
  // finalized in Phase 4 (native `@utcp/mcp`); the proxy try/catches
@@ -487,14 +497,41 @@ export class ToolManualService implements IToolManualService {
487
497
  }
488
498
  const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
489
499
 
490
- // A `.tool` sits under `Groups/`, beside the skills that use it.
500
+ // A `.tool` sits under `Plugins/`, beside the skills that use it.
491
501
  const files: { abs: string; rel: string }[] = [];
492
- const root = path.join(kbRoot, GROUPS_DIR);
502
+ const root = path.join(kbRoot, PLUGINS_DIR);
493
503
  for (const rel of await walkFiles(root, (n) => n.toLowerCase().endsWith('.tool'))) {
494
- files.push({ abs: path.join(root, rel), rel: `${GROUPS_DIR}/${rel}` });
504
+ files.push({ abs: path.join(root, rel), rel: `${PLUGINS_DIR}/${rel}` });
495
505
  }
496
506
 
507
+ // MCP servers come from each plugin's mcp.json — the AUTHORITATIVE source
508
+ // (the Agent Plugins fixed location), synthesized into the same descriptor
509
+ // shape. Listed BEFORE the `.tool` files: on a name collision (a legacy
510
+ // mcp `.tool` the migration has not converted yet), the shared dedup keeps
511
+ // the first occurrence, and the authoritative source must be the one kept.
497
512
  const parsed: ToolManualDescriptor[] = [];
513
+ let pluginFolders: string[] = [];
514
+ try {
515
+ pluginFolders = (await fs.readdir(root, { withFileTypes: true }))
516
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
517
+ .map((e) => e.name)
518
+ .sort();
519
+ } catch {
520
+ /* no Plugins/ root — nothing to scan */
521
+ }
522
+ for (const folder of pluginFolders) {
523
+ let mcpJson: string;
524
+ try {
525
+ mcpJson = await fs.readFile(path.join(root, folder, PLUGIN_MCP_FILE), 'utf-8');
526
+ } catch {
527
+ continue; // no mcp.json is the common case, not an error
528
+ }
529
+ const pluginJson = await fs
530
+ .readFile(path.join(root, folder, PLUGIN_MANIFEST_FILE), 'utf-8')
531
+ .catch(() => null);
532
+ parsed.push(...descriptorsFromMcpJson(folder, mcpJson, pluginJson));
533
+ }
534
+
498
535
  for (const f of files) {
499
536
  let content: string;
500
537
  try {
@@ -654,7 +691,10 @@ export function normalizeToolManual(
654
691
  // reference the platform-seeded variables.
655
692
  assertNoReservedVariableRefs(obj, name);
656
693
 
657
- const descriptor: ToolManualDescriptor = {
694
+ // The non-stdio constituent of the union, by name: `.tool` parsing can
695
+ // never produce a spawn spec, and the stdio side pins `remote: false`,
696
+ // which this builder must stay free to set from the file's own `remote:`.
697
+ const descriptor: ToolManualDescriptorBase & { remote?: boolean; stdio?: undefined } = {
658
698
  slug: provisionalSlug,
659
699
  name,
660
700
  path: repoPath,
@@ -179,9 +179,12 @@ async function buildListLocalToolsDef(svc: IToolManualService, userEmail?: strin
179
179
  description:
180
180
  'List tools your workspace admins configured that run ONLY in a local environment ' +
181
181
  '(e.g. a self-hosted MCP server on localhost) and therefore cannot be called through this ' +
182
- 'remote endpoint. Each entry gives the tool’s name and its `.tool` file path in the knowledge ' +
183
- 'base — read that file with `read_file` and configure the tool yourself in your local setup ' +
184
- '(e.g. add the MCP server to your client). ' +
182
+ 'remote endpoint. To CALL them, run the workspace as a local MCP server instead of this one: ' +
183
+ '`npx @bevel-software/hexis-mcp --url <workspace-url> --key <connection-key>` serves every tool ' +
184
+ 'you have here plus these, because it runs where they exist (their own credentials come from ' +
185
+ 'that process\'s environment). Otherwise each entry gives the tool’s name and its `.tool` file ' +
186
+ 'path in the knowledge base — read that file with `read_file` and wire the tool into your local ' +
187
+ 'setup by hand. ' +
185
188
  (await localToolsLine(svc, userEmail)),
186
189
  path: '/api/agent/tools/list_local_tools',
187
190
  inputs: { type: 'object', properties: {}, additionalProperties: false },
@@ -52,6 +52,7 @@ function makeService(): PendingCommitsService {
52
52
  return {
53
53
  enqueue: vi.fn().mockResolvedValue(undefined),
54
54
  claimNext: vi.fn().mockResolvedValue(null),
55
+ hasReadyRow: vi.fn().mockResolvedValue(false),
55
56
  markSucceeded: vi.fn().mockResolvedValue(undefined),
56
57
  markTransientFailure: vi.fn().mockResolvedValue(undefined),
57
58
  markRecoveryStarted: vi.fn().mockResolvedValue(undefined),
@@ -114,6 +115,8 @@ describe('PendingCommitsWorker.drainOnce', () => {
114
115
  'feat/x',
115
116
  'Foo.md',
116
117
  expect.objectContaining<Partial<AuthUser>>({ email: 'alice@example.com', name: 'Alice' }),
118
+ // Last (only) row of the burst, so the advisory validator runs.
119
+ { skipValidation: false },
117
120
  );
118
121
  expect(service.markSucceeded).toHaveBeenCalledWith('row-1');
119
122
  expect(service.markTransientFailure).not.toHaveBeenCalled();
@@ -221,6 +224,121 @@ describe('PendingCommitsWorker.drainOnce', () => {
221
224
  await expect(worker.drainOnce()).resolves.toBeUndefined();
222
225
  expect(service.markNeedsAttention).toHaveBeenCalled();
223
226
  });
227
+
228
+ /**
229
+ * A sweep used to take exactly ONE row per workspace and then sleep
230
+ * `POLL_INTERVAL_MS`, capping a workspace at ~2 commits/second however fast
231
+ * git was. A bulk change — a migration, an agent run — paid a minute of
232
+ * pure polling latency, and anything waiting for the queue to settle (a
233
+ * change request being applied) waited with it.
234
+ */
235
+ describe('draining a burst', () => {
236
+ /** Queue `count` rows, then nothing — the shape a bulk change leaves. */
237
+ function queue(count: number) {
238
+ const claim = service.claimNext as ReturnType<typeof vi.fn>;
239
+ const ready = service.hasReadyRow as ReturnType<typeof vi.fn>;
240
+ for (let i = 0; i < count; i += 1) {
241
+ claim.mockResolvedValueOnce(makeRow({ id: `row-${i}`, path: `File${i}.md` }));
242
+ // True until the last row is the one in hand.
243
+ ready.mockResolvedValueOnce(i < count - 1);
244
+ }
245
+ claim.mockResolvedValue(null);
246
+ ready.mockResolvedValue(false);
247
+ }
248
+
249
+ it('commits every queued row in ONE pass', async () => {
250
+ queue(12);
251
+ await worker.drainOnce();
252
+ expect(workflow.runPendingCommit).toHaveBeenCalledTimes(12);
253
+ expect(service.markSucceeded).toHaveBeenCalledTimes(12);
254
+ });
255
+
256
+ it('validates once — on the last commit, whose tree is the end state', async () => {
257
+ // The validator parses the whole KB for a report that is only logged,
258
+ // so per-file runs cost a full parse each and all say the same thing.
259
+ queue(5);
260
+ await worker.drainOnce();
261
+ const skipped = workflow.runPendingCommit.mock.calls.map((c) => c[4]?.skipValidation);
262
+ expect(skipped).toEqual([true, true, true, true, false]);
263
+ });
264
+
265
+ it('stops at the burst ceiling so one workspace cannot starve the rest', async () => {
266
+ // The loop holds the single in-flight commit slot; an unbounded drain
267
+ // would park every other user's save behind a huge migration.
268
+ queue(200);
269
+ await worker.drainOnce();
270
+ expect(workflow.runPendingCommit).toHaveBeenCalledTimes(50);
271
+ });
272
+
273
+ it('gives up the pass the moment stop() lands mid-burst', async () => {
274
+ queue(12);
275
+ workflow.runPendingCommit.mockImplementation(async () => {
276
+ (worker as unknown as { running: boolean }).running = false;
277
+ });
278
+ await worker.drainOnce();
279
+ expect(workflow.runPendingCommit).toHaveBeenCalledTimes(1);
280
+ });
281
+
282
+ it('carries on after a transient failure and ends the pass when nothing is ready', async () => {
283
+ // What keeps the drain loop from SPINNING on a failing row is the SQL
284
+ // backoff gate: `markTransientFailure` leaves `lastAttemptedAt` set, so
285
+ // `claimNext` refuses that row until its backoff elapses. That gate is
286
+ // not exercised here — `claimNext` is a stub, so this would pass with
287
+ // the gate removed. It lives in `readyPredicate`, shared by `claimNext`
288
+ // and `hasReadyRow` precisely so the two cannot drift; there is no
289
+ // database-backed test for it in this package.
290
+ //
291
+ // What this DOES pin is the loop's own half of the contract: a failure
292
+ // is recorded and the pass keeps going rather than aborting the sweep,
293
+ // and the row is attempted once.
294
+ (service.claimNext as ReturnType<typeof vi.fn>)
295
+ .mockResolvedValueOnce(makeRow({ id: 'bad' }))
296
+ .mockResolvedValueOnce(makeRow({ id: 'good' }))
297
+ .mockResolvedValue(null);
298
+ workflow.runPendingCommit
299
+ .mockRejectedValueOnce(new Error('push rejected'))
300
+ .mockResolvedValueOnce(undefined);
301
+
302
+ await worker.drainOnce();
303
+
304
+ expect(service.markTransientFailure).toHaveBeenCalledTimes(1);
305
+ expect(service.markSucceeded).toHaveBeenCalledTimes(1);
306
+ expect(workflow.runPendingCommit).toHaveBeenCalledTimes(2);
307
+ });
308
+
309
+ it('validates the final slot when the burst hits its ceiling', async () => {
310
+ // Rows remain queued, so the peek says "more" — but this is the last
311
+ // commit of the PASS, and skipping it would end every sweep of a big
312
+ // backlog on an unvalidated commit.
313
+ queue(200);
314
+ await worker.drainOnce();
315
+ const calls = workflow.runPendingCommit.mock.calls;
316
+ expect(calls).toHaveLength(50);
317
+ expect(calls[49]?.[4]?.skipValidation).toBe(false);
318
+ expect(calls[48]?.[4]?.skipValidation).toBe(true);
319
+ // …and does not ASK on that slot: the ceiling already answers it, so
320
+ // peeking would be a wasted round-trip on every sweep of a backlog.
321
+ expect(service.hasReadyRow).toHaveBeenCalledTimes(49);
322
+ });
323
+
324
+ it('commits the claimed row even if the peek throws', async () => {
325
+ // The row is already claimed and `running`; nothing resets that, so
326
+ // throwing out of the peek would strand it and the workspace would stop
327
+ // draining until the process restarted. A failed peek assumes "last",
328
+ // which costs one extra validation and nothing else.
329
+ (service.claimNext as ReturnType<typeof vi.fn>)
330
+ .mockResolvedValueOnce(makeRow({ id: 'claimed' }))
331
+ .mockResolvedValue(null);
332
+ (service.hasReadyRow as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
333
+ new Error('connection reset'),
334
+ );
335
+
336
+ await expect(worker.drainOnce()).resolves.toBeUndefined();
337
+
338
+ expect(service.markSucceeded).toHaveBeenCalledTimes(1);
339
+ expect(workflow.runPendingCommit.mock.calls[0]?.[4]?.skipValidation).toBe(false);
340
+ });
341
+ });
224
342
  });
225
343
 
226
344
  describe('PendingCommitsWorker lifecycle', () => {
@@ -93,7 +93,7 @@ function makeAccessControl(): IAccessControl {
93
93
  grantSources: vi.fn().mockResolvedValue({}),
94
94
  invalidate: vi.fn(),
95
95
  findEmailByHash: vi.fn().mockResolvedValue(null),
96
- kbPrincipals: vi.fn().mockResolvedValue({ groups: [], people: [] }),
96
+ kbPrincipals: vi.fn().mockResolvedValue({ plugins: [], people: [] }),
97
97
  validateRolesYaml: vi.fn().mockReturnValue({ ok: true }),
98
98
  referencesToRole: vi.fn().mockResolvedValue([]),
99
99
  canWriteAtRef: vi.fn().mockResolvedValue(null),
@@ -49,9 +49,9 @@ describe('assertValidRelativePath', () => {
49
49
  // now sets `GIT_LITERAL_PATHSPECS=1` on every git subprocess, so KB files
50
50
  // arriving with bracketed prefixes (`[Approved]`, `[New]`, `[Updated …]`)
51
51
  // or other glob metacharacters round-trip through commit cleanly.
52
- '[Approved] Purchasing_Order archive.docx',
53
- 'Purchasing/[New] Purchasing_E-Invoicing DE.docx',
54
- 'Purchasing/[Updated 03.09.2025] Purchasing_How to search_Product detail page.docx',
52
+ '[Approved] Handbook_Order archive.docx',
53
+ 'Handbook/[New] Handbook_E-Invoicing DE.docx',
54
+ 'Handbook/[Updated 03.09.2025] Handbook_How to search_Detail page.docx',
55
55
  'docs/wildcard*name.md',
56
56
  'docs/question?name.md',
57
57
  'docs/bang!name.md',
@@ -124,7 +124,7 @@ function recordingAccessControl(opts: {
124
124
  eligibleWritersForPathsAtRef: async (_w, _ref, paths) =>
125
125
  new Map(paths.map((p) => [p, { roles: [], users: [], emails: new Set<string>() }])),
126
126
  findEmailByHash: async () => null,
127
- kbPrincipals: async () => ({ groups: [], people: [] }),
127
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
128
128
  validateRolesYaml: () => ({ ok: true }),
129
129
  referencesToRole: async () => [],
130
130
  };
@@ -279,8 +279,8 @@ describe('GitService — push gate uses origin/<branch> (not HEAD or working tre
279
279
  });
280
280
 
281
281
  it('systemAuthorized skips the gate entirely, and the push still lands', async () => {
282
- // The group-provisioning path: its endpoint IS the authorization (any
283
- // signed-in user may claim an unused name under Groups/), and the gate
282
+ // The plugin-provisioning path: its endpoint IS the authorization (any
283
+ // signed-in user may claim an unused name under Plugins/), and the gate
284
284
  // could only read the new folder's chain at origin as `write: Admin`.
285
285
  const { svc, calls } = await setupWithOrigin();
286
286
  await svc.push(workspaceId, USER, { systemAuthorized: true });