@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +17 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +12 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +10 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -0
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +17 -0
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -1
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +25 -15
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +296 -96
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +81 -31
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +95 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +390 -2
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +8 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +25 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +40 -1
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +5 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +1 -1
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +13 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +61 -33
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +11 -9
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +570 -134
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- package/dist/shared/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +1 -0
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +42 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/__tests__/lifecycle.test.ts +71 -4
- package/src/core/create-core-server.ts +21 -3
- package/src/core/create-core-services.ts +27 -1
- package/src/core/lifecycle.ts +19 -0
- package/src/core-config.ts +18 -0
- package/src/index.ts +25 -0
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- package/src/modules/access/access.routes.ts +4 -1
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
- package/src/modules/auth/account-erasure.service.ts +69 -18
- package/src/modules/auth/auth.service.ts +33 -23
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +81 -31
- package/src/modules/database/migrate.ts +540 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/tool-auth/external-api-key.service.ts +8 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +47 -1
- package/src/modules/workflow/pending-commits.service.ts +5 -1
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.service.ts +4 -1
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +1 -2
- package/src/modules/workspace/workspace.service.ts +63 -37
- package/src/modules/workspace/workspace.tools.ts +647 -148
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- package/src/shared/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
- package/src/tenancy/static-tenant-source.ts +1 -0
- 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 {
|
|
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
|
|
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(`${
|
|
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 {
|
|
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(
|
|
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(`${
|
|
593
|
+
expect(client.getInstructions()).toBe(`${PLATFORM}\n\nAcme builds solar farms.\n\nProjects live in Projects/.`);
|
|
554
594
|
});
|
|
555
595
|
|
|
556
|
-
it('sends the
|
|
596
|
+
it('sends the platform text alone when no reader is wired', async () => {
|
|
557
597
|
const client = await setup();
|
|
558
|
-
expect(client.getInstructions()).toBe(
|
|
598
|
+
expect(client.getInstructions()).toBe(PLATFORM);
|
|
559
599
|
});
|
|
560
600
|
|
|
561
|
-
it('a throwing reader still yields a session, with the
|
|
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(
|
|
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
|
-
|
|
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 ${
|
|
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: [...
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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(
|
|
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');
|