@bevel-software/platform-core-backend 0.23.0 → 0.25.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 (238) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +13 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +95 -1
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +390 -2
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  68. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  69. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  70. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  71. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  72. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp.service.js +38 -8
  74. package/dist/modules/mcp/mcp.service.js.map +1 -1
  75. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  76. package/dist/modules/plugins/join-request-records.store.js +7 -4
  77. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  78. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  80. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  82. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  83. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  84. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  86. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  87. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  88. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  89. package/dist/modules/tool-registry/description-length.js +108 -0
  90. package/dist/modules/tool-registry/description-length.js.map +1 -0
  91. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  92. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/git/git.service.js +40 -1
  94. package/dist/modules/workflow/git/git.service.js.map +1 -1
  95. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/pending-commits.service.js +5 -1
  97. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  98. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  99. package/dist/modules/workflow/recovery-bot.js +7 -3
  100. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  101. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  103. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  104. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/workflow.service.js +4 -1
  106. package/dist/modules/workflow/workflow.service.js.map +1 -1
  107. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  108. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  109. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  110. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  111. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  112. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  113. package/dist/modules/workspace/agent-upload.store.js +553 -0
  114. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  115. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  116. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  117. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  118. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  119. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  120. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  121. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  122. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  123. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  124. package/dist/modules/workspace/upload-limits.js +13 -0
  125. package/dist/modules/workspace/upload-limits.js.map +1 -0
  126. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.routes.js +1 -1
  128. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  129. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  130. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  131. package/dist/modules/workspace/workspace.service.js +61 -33
  132. package/dist/modules/workspace/workspace.service.js.map +1 -1
  133. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  134. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  135. package/dist/modules/workspace/workspace.tools.js +570 -134
  136. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  137. package/dist/modules/workspace/write-denial.d.ts +0 -6
  138. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  139. package/dist/modules/workspace/write-denial.js +0 -6
  140. package/dist/modules/workspace/write-denial.js.map +1 -1
  141. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  142. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  143. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  144. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  145. package/dist/shared/column-crypto.d.ts +194 -0
  146. package/dist/shared/column-crypto.d.ts.map +1 -0
  147. package/dist/shared/column-crypto.js +144 -0
  148. package/dist/shared/column-crypto.js.map +1 -0
  149. package/dist/shared/token-crypto.d.ts.map +1 -1
  150. package/dist/shared/token-crypto.js +25 -1
  151. package/dist/shared/token-crypto.js.map +1 -1
  152. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  153. package/dist/tenancy/static-tenant-source.js +1 -0
  154. package/dist/tenancy/static-tenant-source.js.map +1 -1
  155. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  156. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  157. package/dist/tenancy/tenant-secrets.js +4 -0
  158. package/dist/tenancy/tenant-secrets.js.map +1 -1
  159. package/kb-template/AGENTS.md +42 -0
  160. package/migrations/0016_pii_encryption.sql +20 -0
  161. package/migrations/meta/0016_snapshot.json +2327 -0
  162. package/migrations/meta/_journal.json +7 -0
  163. package/package.json +3 -3
  164. package/src/core/__tests__/lifecycle.test.ts +71 -4
  165. package/src/core/create-core-server.ts +21 -3
  166. package/src/core/create-core-services.ts +27 -1
  167. package/src/core/lifecycle.ts +19 -0
  168. package/src/core-config.ts +18 -0
  169. package/src/index.ts +25 -0
  170. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  171. package/src/modules/access/access.routes.ts +4 -1
  172. package/src/modules/access/directory-sync-bot.ts +7 -3
  173. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  174. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  175. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  176. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  177. package/src/modules/agent-instructions/compose.ts +50 -7
  178. package/src/modules/agent-instructions/index.ts +12 -0
  179. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  180. package/src/modules/audit/agent-audit.service.ts +4 -2
  181. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  182. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  183. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  184. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  185. package/src/modules/auth/account-erasure.service.ts +69 -18
  186. package/src/modules/auth/auth.service.ts +33 -23
  187. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  188. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  189. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  190. package/src/modules/database/__tests__/connection.test.ts +12 -0
  191. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
  192. package/src/modules/database/connection.ts +117 -0
  193. package/src/modules/database/core-schema.ts +81 -31
  194. package/src/modules/database/migrate.ts +540 -2
  195. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  196. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  199. package/src/modules/mcp/mcp.service.ts +46 -7
  200. package/src/modules/plugins/join-request-records.store.ts +7 -4
  201. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  203. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  204. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  205. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  206. package/src/modules/tool-registry/description-length.ts +111 -0
  207. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  208. package/src/modules/workflow/git/git.service.ts +47 -1
  209. package/src/modules/workflow/pending-commits.service.ts +5 -1
  210. package/src/modules/workflow/recovery-bot.ts +7 -3
  211. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  212. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  213. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  214. package/src/modules/workflow/workflow.service.ts +4 -1
  215. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  216. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  217. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  218. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  219. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  220. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  221. package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
  222. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  223. package/src/modules/workspace/agent-upload.store.ts +668 -0
  224. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  225. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  226. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  227. package/src/modules/workspace/upload-limits.ts +12 -0
  228. package/src/modules/workspace/workspace.routes.ts +1 -2
  229. package/src/modules/workspace/workspace.service.ts +63 -37
  230. package/src/modules/workspace/workspace.tools.ts +647 -148
  231. package/src/modules/workspace/write-denial.ts +0 -8
  232. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  233. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  234. package/src/shared/column-crypto.ts +218 -0
  235. package/src/shared/token-crypto.ts +28 -1
  236. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  237. package/src/tenancy/static-tenant-source.ts +1 -0
  238. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -187,6 +187,43 @@ export class LockingFilesystem extends GitGuardedFilesystem {
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * Write a file whose new content DEPENDS on what it holds: the read, the
192
+ * computing of the new bytes and the write all happen with the path's lock
193
+ * held, so what lands is derived from the very bytes it replaces.
194
+ *
195
+ * `writeFile` cannot give that. Its content is fixed before the lock is
196
+ * taken, so a caller that read the file first is writing over a state
197
+ * anyone may have moved in between: an `edit_file` replacing an empty owner
198
+ * field with one name would silently overwrite another name written a
199
+ * moment earlier, and both callers would be told their edit landed.
200
+ *
201
+ * `rewrite` receives the file as it is under the lock (null when nothing is
202
+ * there) and answers the bytes to write. Throwing refuses: nothing is
203
+ * written, the lock is released untouched, and the caller gets what was
204
+ * thrown.
205
+ */
206
+ async rewriteFile(
207
+ inputPath: string,
208
+ rewrite: (current: Buffer | null) => FileContent | Promise<FileContent>,
209
+ ): Promise<void> {
210
+ await this.assertNotGitInternals(inputPath);
211
+ this.assertInsideRepo(inputPath);
212
+ const validate = this.lockContext.validateWrite;
213
+ let toWrite: FileContent | null = null;
214
+ return this.withLock(
215
+ inputPath,
216
+ () => super.writeFile(inputPath, toWrite as FileContent),
217
+ async () => {
218
+ const next = await rewrite(await this.readIfExists(inputPath));
219
+ // The pre-disk gate judges the bytes that will land, as it does for
220
+ // every other write, and here those exist only now.
221
+ await validate?.(inputPath, next);
222
+ toWrite = next;
223
+ },
224
+ );
225
+ }
226
+
190
227
  override async appendFile(inputPath: string, content: FileContent): Promise<void> {
191
228
  await this.assertNotGitInternals(inputPath);
192
229
  this.assertInsideRepo(inputPath);
@@ -16,7 +16,8 @@ import { SpillStore } from '../../workspace/spill-store.js';
16
16
  import { createManualRoutes } from '../../tool-registry/manual.routes.js';
17
17
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
18
18
  import { toolDef } from '../../tool-helpers/tool-def.js';
19
- import { PLATFORM_HEADER } from '../../agent-instructions/index.js';
19
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
20
+ import { platformInstructions } from '../../agent-instructions/index.js';
20
21
  import { startFakeDownstreamMcpServer, type FakeDownstreamMcpServer } from './fake-downstream-mcp-server.js';
21
22
  import { registerBevelSecretsVariableLoader } from '../../secrets-vault/secrets-variable-loader.js';
22
23
  import type { ForcedRefreshOutcome, ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
@@ -483,13 +484,13 @@ describe('per-request identity: catalog, metering and continuity', () => {
483
484
  });
484
485
 
485
486
  describe('agent instructions over the real transport', () => {
486
- it('the initialize result carries the header and the preamble body inline', async () => {
487
+ it('the initialize result carries the platform text and the preamble body inline', async () => {
487
488
  const { baseUrl } = await startPlatform({
488
489
  readAgentPreamble: async () => 'Acme builds solar farms.\n\n<!-- private -->Look in Projects/ first.',
489
490
  });
490
491
  const { client } = await connectSdkClient(baseUrl);
491
492
  const instructions = client.getInstructions();
492
- expect(instructions).toBe(`${PLATFORM_HEADER}\n\nAcme builds solar farms.\n\nLook in Projects/ first.`);
493
+ expect(instructions).toBe(`${platformInstructions(DEFAULT_KB_LAYOUT)}\n\nAcme builds solar farms.\n\nLook in Projects/ first.`);
493
494
  expect(instructions).not.toContain('private');
494
495
  });
495
496
 
@@ -11,9 +11,13 @@ import { SpillStore } from '../../workspace/spill-store.js';
11
11
  import { createManualRoutes } from '../../tool-registry/manual.routes.js';
12
12
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
13
13
  import { toolDef } from '../../tool-helpers/tool-def.js';
14
- import { PLATFORM_HEADER, TOOL_PREFIX_LINE } from '../../agent-instructions/index.js';
14
+ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
15
+ import { TOOL_PREFIX_LINE, platformInstructions, sharedRulesPointer } from '../../agent-instructions/index.js';
15
16
  import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.contract.js';
16
17
 
18
+ /** The platform-owned part of the handshake text: the header plus the shared file rules. */
19
+ const PLATFORM = platformInstructions(DEFAULT_KB_LAYOUT);
20
+
17
21
  /**
18
22
  * End-to-end proxy test: a real express app serving the registry-driven tool
19
23
  * surface over loopback, a real per-request `UtcpClient` discovering it, and a
@@ -262,6 +266,25 @@ describe('McpService (UTCP→MCP proxy)', () => {
262
266
  expect(askSchema.properties.body?.properties?.prompt).toBeDefined();
263
267
  });
264
268
 
269
+ it('ends the served call_tool_chain description with the shared-rules pointer', async () => {
270
+ // What a chained read does to an IMAGE is one of the rules the file tools
271
+ // share, so it is stated once — in the handshake instructions and in the
272
+ // managed guide — and the chain, like every file tool, ends with the one
273
+ // sentence saying where. The clients that drop `instructions` have only
274
+ // descriptions to go on, so that sentence is their way to the rule.
275
+ const client = await setup();
276
+ const { tools } = await client.listTools();
277
+ const chain = tools.find((t) => t.name === 'call_tool_chain')!;
278
+ const pointer = sharedRulesPointer(DEFAULT_KB_LAYOUT);
279
+ expect(chain.description!.endsWith(pointer)).toBe(true);
280
+ // Once, and not on the two meta-tools that describe the registry rather
281
+ // than a file.
282
+ expect(chain.description!.split(pointer)).toHaveLength(2);
283
+ for (const name of ['list_tools', 'tools_info']) {
284
+ expect(tools.find((t) => t.name === name)!.description, name).not.toContain(pointer);
285
+ }
286
+ });
287
+
265
288
  it('a $defs/$ref tool schema survives tools/list and a real MCP client accepts it', async () => {
266
289
  // No tokenId filter for this assertion — an OAuth-style session lists all.
267
290
  const client = await setup({ tokenId: null });
@@ -534,6 +557,23 @@ describe('McpService — per-user credential pre-check', () => {
534
557
  expect(await toolNames(client)).toEqual(['ask', 'boom', 'call_tool_chain', 'list_tools', 'refy', 'tools_info']);
535
558
  });
536
559
 
560
+ /**
561
+ * The worked example in `call_tool_chain`'s description has to be derived
562
+ * from the tools THIS caller is actually served. Derived from the unfiltered
563
+ * catalog it could name a credential-gated tool the filter just removed —
564
+ * an example a connection-key caller copies and cannot call at all.
565
+ */
566
+ it('never writes the worked example against a tool it just hid', async () => {
567
+ const hidden = await setup({ secretsVault: vault(false), toolManuals: manualsWithUserVar });
568
+ const chain = (await hidden.listTools()).tools.find((t) => t.name === 'call_tool_chain')!;
569
+ expect(chain.description).not.toMatch(/return KNOWLEDGE_BASE\.\w+\(/);
570
+ // The same catalog, with the credential set, does print one — so the
571
+ // assertion above is the filter at work, not an example that never exists.
572
+ const served = await setup({ secretsVault: vault(true), toolManuals: manualsWithUserVar });
573
+ const shown = (await served.listTools()).tools.find((t) => t.name === 'call_tool_chain')!;
574
+ expect(shown.description).toMatch(/return KNOWLEDGE_BASE\.\w+\(/);
575
+ });
576
+
537
577
  it('keeps the full listing for an OAuth/JWT session (tokenId null) — the caller configures interactively', async () => {
538
578
  const client = await setup({
539
579
  secretsVault: vault(false),
@@ -548,17 +588,17 @@ describe('McpService — per-user credential pre-check', () => {
548
588
  describe('McpService — agent instructions', () => {
549
589
  const KB_TOOLS = ['start_session', 'grep', 'list_files', 'read_file'];
550
590
 
551
- it('sends the header and the preamble as the session\'s instructions', async () => {
591
+ it("sends the platform text and the preamble as the session's instructions", async () => {
552
592
  const client = await setup({ readAgentPreamble: async () => 'Acme builds solar farms.\n\nProjects live in Projects/.' });
553
- expect(client.getInstructions()).toBe(`${PLATFORM_HEADER}\n\nAcme builds solar farms.\n\nProjects live in Projects/.`);
593
+ expect(client.getInstructions()).toBe(`${PLATFORM}\n\nAcme builds solar farms.\n\nProjects live in Projects/.`);
554
594
  });
555
595
 
556
- it('sends the header alone when no reader is wired', async () => {
596
+ it('sends the platform text alone when no reader is wired', async () => {
557
597
  const client = await setup();
558
- expect(client.getInstructions()).toBe(PLATFORM_HEADER);
598
+ expect(client.getInstructions()).toBe(PLATFORM);
559
599
  });
560
600
 
561
- it('a throwing reader still yields a session, with the header as its instructions and a warning', async () => {
601
+ it('a throwing reader still yields a session, with the platform text as its instructions and a warning', async () => {
562
602
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
563
603
  const client = await setup({
564
604
  readAgentPreamble: async () => {
@@ -566,7 +606,7 @@ describe('McpService — agent instructions', () => {
566
606
  },
567
607
  extraTools: KB_TOOLS,
568
608
  });
569
- expect(client.getInstructions()).toBe(PLATFORM_HEADER);
609
+ expect(client.getInstructions()).toBe(PLATFORM);
570
610
  // The error itself rides along, so a terminal shows its stack.
571
611
  expect(warn).toHaveBeenCalledWith(
572
612
  expect.stringContaining('mcp-description.md'),
@@ -27,7 +27,7 @@ import {
27
27
  } from '@utcp/sdk';
28
28
  import { CodeModeUtcpClient } from '@utcp/code-mode';
29
29
  import {
30
- CODE_MODE_META_TOOLS,
30
+ codeModeMetaTools,
31
31
  META_TOOL_NAMES,
32
32
  dispatchMetaTool,
33
33
  dispatchToolCall,
@@ -43,6 +43,7 @@ import {
43
43
  type SkillSummary,
44
44
  type LoadedSkill,
45
45
  } from '@bevel-software/platform-mcp-core';
46
+ import type { KbLayout } from '@bevel-software/platform-shared';
46
47
  import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
47
48
  import {
48
49
  scopesCovered,
@@ -65,6 +66,7 @@ import {
65
66
  composeAgentInstructions,
66
67
  prefixToolDescription,
67
68
  PREFIXED_TOOLS,
69
+ sharedRulesPointer,
68
70
  type AgentPreambleReader,
69
71
  type ComposedAgentInstructions,
70
72
  } from '../agent-instructions/index.js';
@@ -91,6 +93,13 @@ export interface McpProxyOptions {
91
93
  * carries the platform header alone.
92
94
  */
93
95
  readAgentPreamble?: AgentPreambleReader;
96
+ /**
97
+ * The layout in effect, read per request: the shared file rules name the
98
+ * managed guide, and a deployment may rename it after boot (the setup save
99
+ * applies a name without a restart). A GETTER, so nothing snapshots the
100
+ * pre-setup default. Absent, the rules name `AGENTS.md`.
101
+ */
102
+ kbLayout?: () => KbLayout;
94
103
  /** Bounds of the downstream (`mcp.json`) connection pool; defaults are 4h idle / 5000 entries. */
95
104
  downstreamPool?: Pick<DownstreamPoolOptions<unknown>, 'idleTtlMs' | 'maxEntries' | 'now'>;
96
105
  /**
@@ -434,12 +443,26 @@ export class McpService {
434
443
  const seen = new Set(META_TOOL_NAMES);
435
444
  const dropped: string[] = [];
436
445
  const direct: McpTool[] = [];
446
+ const examplePool: ProxiedTool[] = [];
437
447
  for (const t of listed) {
438
448
  const entry = toListedTool(t); // logs its own reason on a name/schema drop
439
449
  if (!entry) {
440
450
  dropped.push(t.mcpName);
441
451
  continue;
442
452
  }
453
+ // Kept for the worked example in the meta-tool descriptions: it must
454
+ // be derived from the tools THIS caller actually gets, not from the
455
+ // whole catalog, or a connection-key caller is shown an example naming
456
+ // a credential-gated tool the filter above just removed from its
457
+ // listing — a copied call that cannot work.
458
+ //
459
+ // BEFORE the duplicate drop below, on purpose. A tool dropped from the
460
+ // LISTING for sharing its name with another is still in the catalog a
461
+ // chain dispatches to, so the chain sees two tools under one name and
462
+ // refuses the call as ambiguous. The example has to know about both to
463
+ // steer clear of either (`chainExample` skips a name two tools share);
464
+ // shown only the survivor, it took that name for a safe one.
465
+ examplePool.push(t);
443
466
  if (seen.has(entry.name)) {
444
467
  dropped.push(`${entry.name} (duplicate)`);
445
468
  continue;
@@ -456,18 +479,33 @@ export class McpService {
456
479
  : entry,
457
480
  );
458
481
  }
482
+ // The meta-tools' examples name the namespace THIS endpoint registers the
483
+ // knowledge-base tools under, and a tool this caller is really served, so
484
+ // a chain copied out of the description runs. Built here rather than held
485
+ // as a constant: the local MCP server registers the same tools under a
486
+ // different name, and one fixed example is necessarily wrong on one of the
487
+ // two surfaces.
488
+ //
489
+ // The chain's description ends with the same pointer every file tool ends
490
+ // with: what a chain does with a failure, a large result or an image is
491
+ // stated once, in the shared rules, and the clients that drop
492
+ // `instructions` have the description and the guide to go on. Composed
493
+ // here because the guide's name is this deployment's setting.
494
+ const metaTools = codeModeMetaTools(EXTERNAL_KB_MANUAL_NAME, examplePool, {
495
+ sharedRulesPointer: sharedRulesPointer(this.opts.kbLayout?.()),
496
+ });
459
497
  // Log only when a tool was dropped (name/schema/duplicate) — that's the
460
498
  // anomaly worth surfacing, since a downstream client would otherwise hide
461
499
  // it by rejecting the whole response.
462
500
  if (dropped.length) {
463
501
  log.warn(
464
- `tools/list: serving ${CODE_MODE_META_TOOLS.length + direct.length} tool(s); ` +
502
+ `tools/list: serving ${metaTools.length + direct.length} tool(s); ` +
465
503
  `dropped ${dropped.length} non-listable: ${dropped.join(', ')}`,
466
504
  );
467
505
  }
468
506
  return {
469
507
  // Code-mode meta-tools first, then every validated direct tool.
470
- tools: [...CODE_MODE_META_TOOLS, ...direct],
508
+ tools: [...metaTools, ...direct],
471
509
  };
472
510
  });
473
511
 
@@ -613,13 +651,14 @@ export class McpService {
613
651
  * fails over its preamble.
614
652
  */
615
653
  private async composeAgentInstructions(): Promise<ComposedAgentInstructions> {
654
+ const layout = this.opts.kbLayout?.();
616
655
  const read = this.opts.readAgentPreamble;
617
- if (!read) return composeAgentInstructions(null);
656
+ if (!read) return composeAgentInstructions(null, layout);
618
657
  try {
619
- return composeAgentInstructions(await read());
658
+ return composeAgentInstructions(await read(), layout);
620
659
  } catch (err) {
621
- log.warn('could not read mcp-description.md; this request gets the platform header alone:', { err });
622
- return composeAgentInstructions(null);
660
+ log.warn('could not read mcp-description.md; this request gets the platform text alone:', { err });
661
+ return composeAgentInstructions(null, layout);
623
662
  }
624
663
  }
625
664
 
@@ -143,11 +143,14 @@ export class DbJoinRequestStore implements JoinRequestStore {
143
143
  pluginKey: string;
144
144
  }): Promise<JoinRequestRecord> {
145
145
  const requesterEmail = input.requesterEmail.toLowerCase();
146
+ // Keyed by the blind index throughout: the email column is randomized
147
+ // ciphertext, so the uniqueness and every lookup go through its index,
148
+ // which is written and compared with the address.
146
149
  const [inserted] = await this.db
147
150
  .insert(pluginJoinRequests)
148
- .values({ requesterEmail, requesterName: input.requesterName, pluginKey: input.pluginKey })
151
+ .values({ requesterEmail, requesterEmailBidx: requesterEmail, requesterName: input.requesterName, pluginKey: input.pluginKey })
149
152
  .onConflictDoNothing({
150
- target: [pluginJoinRequests.requesterEmail, pluginJoinRequests.pluginKey],
153
+ target: [pluginJoinRequests.requesterEmailBidx, pluginJoinRequests.pluginKey],
151
154
  })
152
155
  .returning();
153
156
  if (inserted) return toRecord(inserted);
@@ -170,7 +173,7 @@ export class DbJoinRequestStore implements JoinRequestStore {
170
173
  })
171
174
  .where(
172
175
  and(
173
- eq(pluginJoinRequests.requesterEmail, requesterEmail),
176
+ eq(pluginJoinRequests.requesterEmailBidx, requesterEmail),
174
177
  eq(pluginJoinRequests.pluginKey, input.pluginKey),
175
178
  ),
176
179
  )
@@ -191,7 +194,7 @@ export class DbJoinRequestStore implements JoinRequestStore {
191
194
  const rows = await this.db
192
195
  .select()
193
196
  .from(pluginJoinRequests)
194
- .where(eq(pluginJoinRequests.requesterEmail, requesterEmail.toLowerCase()));
197
+ .where(eq(pluginJoinRequests.requesterEmailBidx, requesterEmail));
195
198
  return rows.map(toRecord);
196
199
  }
197
200
 
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { logger } from '../../shared/logging.js';
3
3
 
4
4
  const log = logger('external-api-key');
5
- import { and, asc, desc, eq, isNotNull, isNull, type SQL } from 'drizzle-orm';
5
+ import { and, desc, eq, isNotNull, isNull, type SQL } from 'drizzle-orm';
6
6
  import type { AuthUser } from '@bevel-software/platform-shared';
7
7
  import type { Database } from '../database/connection.js';
8
8
  import { externalApiKeys, users } from '../database/schema.js';
@@ -169,6 +169,11 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
169
169
  // Owner joined in so the admin overview is one round-trip; ordered by
170
170
  // owner email so per-account grouping is a linear pass, newest key
171
171
  // first within an account (same order the owner sees on their own page).
172
+ // The email half of that order is applied in-process: the column is
173
+ // ciphertext in the database, where ORDER BY would sort by IV noise. The
174
+ // sort is stable, so the database's newest-first survives within an owner.
175
+ // By code unit, not by locale: a collation can call two different
176
+ // addresses equal, and equal here means "the same owner's group".
172
177
  const rows = await this.db
173
178
  .select({
174
179
  key: externalApiKeys,
@@ -178,7 +183,8 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
178
183
  })
179
184
  .from(externalApiKeys)
180
185
  .innerJoin(users, eq(externalApiKeys.userId, users.id))
181
- .orderBy(asc(users.email), desc(externalApiKeys.createdAt));
186
+ .orderBy(desc(externalApiKeys.createdAt));
187
+ rows.sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0));
182
188
  return rows.map((row) => ({
183
189
  ...toSummary(row.key),
184
190
  user: { id: row.userId, email: row.email, name: row.name },
@@ -183,6 +183,157 @@ describe('ToolManualService', () => {
183
183
  expect(manual).toBeNull();
184
184
  });
185
185
 
186
+ test('resolves an inline tool that calls Google as a service account, and surfaces the key variable', async () => {
187
+ // The auth block is what makes the call; the key it names has to reach the
188
+ // secrets UI as a shared variable or nobody can store it.
189
+ await writeFile(
190
+ join(root, wsId, KB_DIR, 'Plugins', 'google_ads.tool'),
191
+ JSON.stringify({
192
+ id: 'google_ads',
193
+ type: 'inline',
194
+ tools: [
195
+ {
196
+ name: 'list_accessible_customers',
197
+ description: 'List the customers the service account can reach.',
198
+ inputs: { type: 'object', properties: {} },
199
+ outputs: { type: 'object', properties: {} },
200
+ tool_call_template: {
201
+ call_template_type: 'http',
202
+ http_method: 'GET',
203
+ url: 'https://googleads.googleapis.com/v22/customers:listAccessibleCustomers',
204
+ headers: { 'developer-token': '${DEVELOPER_TOKEN}' },
205
+ auth: {
206
+ auth_type: 'google_service_account',
207
+ credentials: '${GOOGLE_SA_KEY}',
208
+ scopes: 'https://www.googleapis.com/auth/adwords',
209
+ },
210
+ },
211
+ },
212
+ ],
213
+ }),
214
+ );
215
+
216
+ const manual = await svc().resolveInlineManual('user@x.eu', 'google_ads');
217
+ const tools = (manual as { tools?: { name?: string; tool_call_template?: { auth?: unknown } }[] } | null)?.tools ?? [];
218
+ expect(tools.map((t) => t.name)).toEqual(['list_accessible_customers']);
219
+ // The auth block is what makes the call, so resolving must carry it through intact.
220
+ expect(tools[0]?.tool_call_template?.auth).toEqual({
221
+ auth_type: 'google_service_account',
222
+ credentials: '${GOOGLE_SA_KEY}',
223
+ scopes: 'https://www.googleapis.com/auth/adwords',
224
+ });
225
+
226
+ const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'google_ads')!;
227
+ expect(summary.variables?.find((v) => v.name === 'GOOGLE_SA_KEY')?.scope).toBe('admin');
228
+ });
229
+
230
+ test('refuses a Google service-account auth block where no token would be sent, and says where', async () => {
231
+ // Only the `http` protocol mints the token. On any other template the
232
+ // block validates and is then sent as no credentials at all, so the file
233
+ // is refused when it is read rather than left to fail at Google.
234
+ const serviceAccount = {
235
+ auth_type: 'google_service_account',
236
+ credentials: '${GOOGLE_SA_KEY}',
237
+ scopes: 'https://www.googleapis.com/auth/adwords',
238
+ };
239
+ const inlineOver = (callTemplateType: string) =>
240
+ JSON.stringify({
241
+ type: 'inline',
242
+ tools: [
243
+ {
244
+ name: 'events',
245
+ description: 'Stream events.',
246
+ inputs: { type: 'object', properties: {} },
247
+ outputs: { type: 'object', properties: {} },
248
+ tool_call_template: { call_template_type: callTemplateType, url: 'https://api.example.com/events', auth: serviceAccount },
249
+ },
250
+ ],
251
+ });
252
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
253
+ await writeFile(join(plugins, 'over_sse.tool'), inlineOver('sse'));
254
+ await writeFile(join(plugins, 'over_streamable.tool'), inlineOver('streamable_http'));
255
+ // Not on a call template at all: a discovered manual takes only a url and headers.
256
+ await writeFile(
257
+ join(plugins, 'discovered.tool'),
258
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', auth: serviceAccount }),
259
+ );
260
+ // Looking like an `http` call template is not enough: a discovered manual
261
+ // reads neither a block at its root nor a `tools` list, whatever they say.
262
+ await writeFile(
263
+ join(plugins, 'dressed_root.tool'),
264
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', call_template_type: 'http', auth: serviceAccount }),
265
+ );
266
+ await writeFile(
267
+ join(plugins, 'unread_tools.tool'),
268
+ JSON.stringify({ ...JSON.parse(inlineOver('http')), type: 'http', url: 'https://api.example.com/utcp' }),
269
+ );
270
+ await writeFile(join(plugins, 'over_http.tool'), inlineOver('http'));
271
+
272
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
273
+
274
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
275
+ expect(reasonFor('over_sse.tool')).toContain('a `sse` call template');
276
+ expect(reasonFor('over_streamable.tool')).toContain('a `streamable_http` call template');
277
+ expect(reasonFor('discovered.tool')).toContain("not a call template's `auth`");
278
+ expect(reasonFor('dressed_root.tool')).toContain('no tool is called through');
279
+ expect(reasonFor('unread_tools.tool')).toContain('no tool is called through');
280
+ for (const file of ['over_sse.tool', 'over_streamable.tool', 'discovered.tool', 'dressed_root.tool', 'unread_tools.tool']) {
281
+ expect(reasonFor(file)).toContain('`call_template_type: http`');
282
+ }
283
+ // The one place it works is left alone.
284
+ expect(reasonFor('over_http.tool')).toBe('');
285
+ expect(catalog.tools.map((m) => m.name)).toContain('overhttp');
286
+ });
287
+
288
+ test('refuses a `.tool` that holds a service-account key itself, wherever the block sits, and never repeats it', async () => {
289
+ // A `.tool` is committed and read by everyone who can read the knowledge
290
+ // base, so the key lives in the vault and the file only names it.
291
+ const key = JSON.stringify({ type: 'service_account', client_email: 'ads@proj.iam.gserviceaccount.com', private_key: 'MIIEvQIBADANBgkq-NOT-A-REAL-KEY' });
292
+ const withCredentials = (credentials: unknown, callTemplateType = 'http') =>
293
+ JSON.stringify({
294
+ type: 'inline',
295
+ tools: [
296
+ {
297
+ name: 'list',
298
+ description: 'List.',
299
+ inputs: { type: 'object', properties: {} },
300
+ outputs: { type: 'object', properties: {} },
301
+ tool_call_template: {
302
+ call_template_type: callTemplateType,
303
+ http_method: 'GET',
304
+ url: 'https://googleads.googleapis.com/v22/customers',
305
+ auth: { auth_type: 'google_service_account', credentials, scopes: 'https://www.googleapis.com/auth/adwords' },
306
+ },
307
+ },
308
+ ],
309
+ });
310
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
311
+ await writeFile(join(plugins, 'pasted_key.tool'), withCredentials(key));
312
+ await writeFile(join(plugins, 'pasted_base64.tool'), withCredentials(Buffer.from(key).toString('base64')));
313
+ // A variable with the key, or anything else, written around it is still text in the file.
314
+ await writeFile(join(plugins, 'beside_a_variable.tool'), withCredentials('${GOOGLE_SA_KEY}' + key));
315
+ // In a block nothing acts on, the key is just as readable: that it is there is said first.
316
+ await writeFile(join(plugins, 'key_over_sse.tool'), withCredentials(key, 'sse'));
317
+ await writeFile(join(plugins, 'braced.tool'), withCredentials('${GOOGLE_SA_KEY}'));
318
+ await writeFile(join(plugins, 'bare.tool'), withCredentials(' $GOOGLE_SA_KEY '));
319
+
320
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
321
+
322
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
323
+ for (const file of ['pasted_key.tool', 'pasted_base64.tool', 'beside_a_variable.tool', 'key_over_sse.tool']) {
324
+ expect(reasonFor(file), file).toContain('something other than a vault variable as its `credentials`');
325
+ expect(reasonFor(file), file).toContain('Secrets Vault');
326
+ }
327
+ // Nothing of what was pasted comes back in the answer, in either spelling.
328
+ const answer = JSON.stringify(catalog);
329
+ expect(answer).not.toMatch(/NOT-A-REAL-KEY|iam\.gserviceaccount/);
330
+ expect(answer).not.toContain(Buffer.from(key).toString('base64').slice(0, 40));
331
+ // Either spelling of one variable is what the field is for.
332
+ expect(reasonFor('braced.tool')).toBe('');
333
+ expect(reasonFor('bare.tool')).toBe('');
334
+ expect(catalog.tools.map((m) => m.name)).toEqual(expect.arrayContaining(['braced', 'bare']));
335
+ });
336
+
186
337
  test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
187
338
  root = await mkdtemp(join(tmpdir(), 'tools2-'));
188
339
  const tools = join(root, wsId, KB_DIR, 'Plugins');
@@ -16,6 +16,9 @@ import {
16
16
  DefaultVariableSubstitutor,
17
17
  type CallTemplate,
18
18
  } from '@utcp/sdk';
19
+ // Also a side effect: loading the package registers the 'google_service_account'
20
+ // auth type, so a `.tool` naming it validates here.
21
+ import { findUnservedGoogleServiceAccountAuth, holdsLiteralGoogleServiceAccountKey } from '@bevel-software/platform-mcp-core';
19
22
  import { descriptorsFromMcpJson } from './mcp-json-discovery.js';
20
23
  import type { PluginSource } from '../plugins/discovery/plugin-source.js';
21
24
  import type { WorkspaceService } from '../workspace/workspace.service.js';
@@ -1196,6 +1199,40 @@ export function normalizeToolManual(
1196
1199
  descriptor.remote = false;
1197
1200
  }
1198
1201
 
1202
+ // A service account's KEY IS NEVER WRITTEN IN A `.tool`. The file is
1203
+ // knowledge-base content: committed, and read by everyone and every agent
1204
+ // that can read the knowledge base. So `credentials` names the vault
1205
+ // variable and nothing else, and a file that holds anything else there is
1206
+ // refused before the block's placement is even looked at: a key in a block
1207
+ // nothing acts on is just as readable. The refusal does not quote the value.
1208
+ if (holdsLiteralGoogleServiceAccountKey(obj)) {
1209
+ throw new Error(
1210
+ '`auth_type: google_service_account` has something other than a vault variable as its `credentials`. ' +
1211
+ 'Write `credentials: ${GOOGLE_SA_KEY}` and store the key in the Secrets Vault under that name: a `.tool` is ' +
1212
+ 'read by everyone who can read the knowledge base. If a real key was saved in this file, treat it as ' +
1213
+ 'exposed and replace it at Google; removing it here does not take it out of the history.',
1214
+ );
1215
+ }
1216
+
1217
+ // A Google service-account `auth` block works on one thing: an inline tool's
1218
+ // `http` call template. Anywhere else it validates and is then sent as no
1219
+ // credentials at all, so the tool would save cleanly and call Google
1220
+ // unauthenticated, with Google's 401 the only hint. Refused here instead,
1221
+ // naming where the block was found. The templates below are the only ones
1222
+ // this file's tools are called through: a `tools` list on a file that is not
1223
+ // `inline` is never read, so nothing in it counts.
1224
+ const toolCallTemplates =
1225
+ type === 'inline' && Array.isArray(obj.tools)
1226
+ ? obj.tools.map((tool: unknown) => (tool && typeof tool === 'object' ? (tool as Record<string, unknown>).tool_call_template : undefined))
1227
+ : [];
1228
+ const unserved = findUnservedGoogleServiceAccountAuth(obj, toolCallTemplates);
1229
+ if (unserved) {
1230
+ throw new Error(
1231
+ `\`auth_type: google_service_account\` is on ${unserved}, where no token would be sent. ` +
1232
+ "It works only as the `auth` of an inline tool's `tool_call_template` with `call_template_type: http`.",
1233
+ );
1234
+ }
1235
+
1199
1236
  if (type === 'inline') {
1200
1237
  const tools = Array.isArray(obj.tools) ? obj.tools : undefined;
1201
1238
  if (!tools) throw new Error('inline `.tool` must have a `tools` array');