@bevel-software/platform-core-backend 0.7.5 → 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 (230) 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 +30 -18
  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/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /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 },
@@ -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 });
@@ -139,8 +139,10 @@ describe('GitService.commitFile — skipValidator', () => {
139
139
  'knowledge-base',
140
140
  );
141
141
 
142
- const relPath = 'Purchasing/[Updated 03.09.2025] Purchasing_How to search_Product detail page.md';
143
- await fs.mkdir(path.join(repo, 'Purchasing'), { recursive: true });
142
+ // Shape mirrors the reported filename (bracketed status prefix carrying a
143
+ // date, underscores, spaces) without reproducing anyone's actual content.
144
+ const relPath = 'Handbook/[Updated 03.09.2025] Handbook_How to search_Detail page.md';
145
+ await fs.mkdir(path.join(repo, 'Handbook'), { recursive: true });
144
146
  await fs.writeFile(path.join(repo, relPath), 'content\n');
145
147
 
146
148
  const change = await svc.commitFile(workspaceId, USER, relPath);
@@ -168,15 +170,18 @@ describe('GitService.commitFile — skipValidator', () => {
168
170
  'knowledge-base',
169
171
  );
170
172
 
173
+ // Synthetic segments matching the SHAPE of real deep-KB naming (numbered
174
+ // prefixes, parenthesised abbreviations, kebab tails) — the property under
175
+ // test is only the total length, asserted below.
171
176
  const deepDir = path.join(
172
- 'Product',
177
+ 'Handbook',
173
178
  'Knowledge',
174
- '1. Service Commitment Management',
175
- '1.1 In - Architecture Layering (AL)',
176
- '0-Governance',
177
- '0.5-Layer-Group-Specific-Governance-Principles',
179
+ '1. First-Level Section With A Long Name',
180
+ '1.1 In - Second-Level Subsection (SL)',
181
+ '0-Reference',
182
+ '0.5-Third-Level-Reference-Naming-Convention',
178
183
  );
179
- const fileName = '0.5.5-Buyer-Side-Commercial-Agreement-Governance.md';
184
+ const fileName = '0.5.5-A-Deeply-Nested-Reference-Document-With-A-Long-Name.md';
180
185
  const relPath = path.posix.join(deepDir.split(path.sep).join('/'), fileName);
181
186
  expect(`Update ${relPath}`.length).toBeGreaterThan(200);
182
187
 
@@ -134,7 +134,7 @@ function fakeAccessControl(adminEmails: string[]): IAccessControl {
134
134
  canOwner: async () => notImpl(),
135
135
  invalidate: () => {},
136
136
  findEmailByHash: async () => null,
137
- kbPrincipals: async () => ({ groups: [], people: [] }),
137
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
138
138
  validateRolesYaml: () => ({ ok: true }),
139
139
  referencesToRole: async () => [],
140
140
  canWriteAtRef: async () => null,
@@ -64,7 +64,7 @@ function makeAccessControl(byRef: WritersByRefAndPath): IAccessControl {
64
64
  eligibleWritersAtRef: async () => null,
65
65
  eligibleWritersForPathsAtRef: async () => null,
66
66
  findEmailByHash: async () => null,
67
- kbPrincipals: async () => ({ groups: [], people: [] }),
67
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
68
68
  validateRolesYaml: () => ({ ok: true }),
69
69
  referencesToRole: async () => [],
70
70
  };
@@ -1047,12 +1047,12 @@ export class GitService implements IGitService {
1047
1047
  // protected pushes; that's defence-in-depth against local rebases or
1048
1048
  // `commit-tree` shenanigans that might bypass the commit-time gate.
1049
1049
  // `systemAuthorized` skips the gate for flows whose ENDPOINT is the
1050
- // authorization — group provisioning commits an access.md into a
1050
+ // authorization — plugin provisioning commits an access.md into a
1051
1051
  // folder that does not exist at origin yet, which this gate can only
1052
1052
  // ever read as "write: Admin". The provisioning service has already
1053
1053
  // decided the write is legitimate (unused name, exclusive create);
1054
1054
  // gating it here again just refuses every non-admin the product
1055
- // promised a group to.
1055
+ // promised a plugin to.
1056
1056
  if (isProtectedBranch(branch) && !opts?.systemAuthorized) {
1057
1057
  const touched = await this.unpushedTouchedPaths(cwd);
1058
1058
  await this.assertCanWriteAtRef(
@@ -164,7 +164,7 @@ function makeAccessControl(byPath: EligibilityByPath): IAccessControl {
164
164
  return result;
165
165
  },
166
166
  findEmailByHash: async () => null,
167
- kbPrincipals: async () => ({ groups: [], people: [] }),
167
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
168
168
  validateRolesYaml: () => ({ ok: true }),
169
169
  referencesToRole: async () => [],
170
170
  };
@@ -53,7 +53,7 @@ function makeAccessControl(adminEmails: string[] = []): IAccessControl {
53
53
  eligibleWritersAtRef: async () => null,
54
54
  eligibleWritersForPathsAtRef: async () => null,
55
55
  findEmailByHash: async () => null,
56
- kbPrincipals: async () => ({ groups: [], people: [] }),
56
+ kbPrincipals: async () => ({ plugins: [], people: [] }),
57
57
  validateRolesYaml: () => ({ ok: true }),
58
58
  referencesToRole: async () => [],
59
59
  };