@bevel-software/platform-core-backend 0.13.5 → 0.14.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/THIRD-PARTY-NOTICES.md +639 -433
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +6 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +7 -2
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -76,6 +76,10 @@ async function baseUrlWith(auth: { userId?: string; email?: string }): Promise<s
|
|
|
76
76
|
secretsVault,
|
|
77
77
|
toolManualService,
|
|
78
78
|
accessControl,
|
|
79
|
+
// These tests are about the credential write, not the probe.
|
|
80
|
+
connectionProbe: {
|
|
81
|
+
probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
|
|
82
|
+
},
|
|
79
83
|
stateSecret: 'test-secret',
|
|
80
84
|
publicBackendUrl: 'http://localhost:3000',
|
|
81
85
|
publicFrontendUrl: 'http://localhost:5173',
|
|
@@ -174,6 +178,10 @@ describe('GET /api/connect/pending — OAuth scope coverage', () => {
|
|
|
174
178
|
secretsVault: vaultWithGranted(grantedScopes),
|
|
175
179
|
toolManualService: oauthTool,
|
|
176
180
|
accessControl,
|
|
181
|
+
// These tests are about the credential write, not the probe.
|
|
182
|
+
connectionProbe: {
|
|
183
|
+
probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
|
|
184
|
+
},
|
|
177
185
|
stateSecret: 'test-secret',
|
|
178
186
|
publicBackendUrl: 'http://localhost:3000',
|
|
179
187
|
publicFrontendUrl: 'http://localhost:5173',
|
|
@@ -257,6 +265,10 @@ describe('GET /api/connect/pending — tool sign-ins are not double-listed as st
|
|
|
257
265
|
secretsVault: vault,
|
|
258
266
|
toolManualService: oauthTool,
|
|
259
267
|
accessControl,
|
|
268
|
+
// These tests are about the credential write, not the probe.
|
|
269
|
+
connectionProbe: {
|
|
270
|
+
probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
|
|
271
|
+
},
|
|
260
272
|
stateSecret: 'test-secret',
|
|
261
273
|
publicBackendUrl: 'http://localhost:3000',
|
|
262
274
|
publicFrontendUrl: 'http://localhost:5173',
|
|
@@ -31,8 +31,7 @@ const TOOL_PATH = 'Tools/weather.tool';
|
|
|
31
31
|
const USER = 'user@x.com';
|
|
32
32
|
const RETURN_TO = '/skills-and-tools/tools/weather';
|
|
33
33
|
|
|
34
|
-
const
|
|
35
|
-
listAccessible: async () => [
|
|
34
|
+
const MANUALS = [
|
|
36
35
|
{
|
|
37
36
|
slug: 'weather',
|
|
38
37
|
name: 'weather',
|
|
@@ -51,7 +50,10 @@ const toolManualService = {
|
|
|
51
50
|
},
|
|
52
51
|
],
|
|
53
52
|
},
|
|
54
|
-
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const toolManualService = {
|
|
56
|
+
listAccessible: async () => MANUALS,
|
|
55
57
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
|
|
56
58
|
|
|
57
59
|
const accessControl = {
|
|
@@ -65,10 +67,15 @@ const secretsVault = {
|
|
|
65
67
|
completeOAuth,
|
|
66
68
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
|
|
67
69
|
|
|
70
|
+
const connectionProbe = {
|
|
71
|
+
probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
|
|
72
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
|
|
73
|
+
|
|
68
74
|
const deps = {
|
|
69
75
|
secretsVault,
|
|
70
76
|
toolManualService,
|
|
71
77
|
accessControl,
|
|
78
|
+
connectionProbe,
|
|
72
79
|
stateSecret: STATE_SECRET,
|
|
73
80
|
publicBackendUrl: 'http://localhost:3000',
|
|
74
81
|
publicFrontendUrl: FRONTEND,
|
|
@@ -28,6 +28,9 @@ const toolManualService = {
|
|
|
28
28
|
setup: { kind: 'oauth-manual' as const, reason: 'no authorization-server metadata at https://weather.example' },
|
|
29
29
|
variables: [
|
|
30
30
|
{ name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
|
|
31
|
+
// A plain per-user key, so the user-scoped write path (and the
|
|
32
|
+
// per-user invalidation it owes) is exercised alongside the shared one.
|
|
33
|
+
{ name: 'MY_KEY', scope: 'user' as const, label: 'Your key' },
|
|
31
34
|
{
|
|
32
35
|
name: 'SIGNIN',
|
|
33
36
|
scope: 'user' as const,
|
|
@@ -56,9 +59,11 @@ const toolManualService = {
|
|
|
56
59
|
],
|
|
57
60
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
|
|
58
61
|
|
|
62
|
+
const putStatic = vi.fn(async () => ({ id: 'u1' }));
|
|
59
63
|
const putSharedStatic = vi.fn(async () => ({ id: 's1' }));
|
|
60
64
|
const putSharedOAuthClientSecret = vi.fn(async () => {});
|
|
61
65
|
const secretsVault = {
|
|
66
|
+
putStatic,
|
|
62
67
|
putSharedStatic,
|
|
63
68
|
putSharedOAuthClientSecret,
|
|
64
69
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
|
|
@@ -69,6 +74,10 @@ const accessControl = {
|
|
|
69
74
|
canWrite: async (_ws: string, email: string, path: string) => email === WRITER && path === TOOL_PATH,
|
|
70
75
|
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
|
|
71
76
|
|
|
77
|
+
const connectionProbe = {
|
|
78
|
+
probe: async () => ({ status: 'unverifiable' as const, detail: null, checkedAt: new Date() }),
|
|
79
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['connectionProbe'];
|
|
80
|
+
|
|
72
81
|
let httpServer: HttpServer | undefined;
|
|
73
82
|
|
|
74
83
|
async function baseUrlAs(email: string): Promise<string> {
|
|
@@ -85,6 +94,7 @@ async function baseUrlAs(email: string): Promise<string> {
|
|
|
85
94
|
secretsVault,
|
|
86
95
|
toolManualService,
|
|
87
96
|
accessControl,
|
|
97
|
+
connectionProbe,
|
|
88
98
|
stateSecret: 'test-secret',
|
|
89
99
|
publicBackendUrl: 'http://localhost:3000',
|
|
90
100
|
publicFrontendUrl: 'http://localhost:5173',
|
|
@@ -102,6 +112,7 @@ afterEach(async () => {
|
|
|
102
112
|
httpServer = undefined;
|
|
103
113
|
putSharedStatic.mockClear();
|
|
104
114
|
putSharedOAuthClientSecret.mockClear();
|
|
115
|
+
putStatic.mockClear();
|
|
105
116
|
});
|
|
106
117
|
|
|
107
118
|
describe('tool owner gate — shared config requires WRITE on the `.tool` file', () => {
|
|
@@ -116,6 +127,21 @@ describe('tool owner gate — shared config requires WRITE on the `.tool` file',
|
|
|
116
127
|
expect(putSharedStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_SHARED_KEY' }));
|
|
117
128
|
});
|
|
118
129
|
|
|
130
|
+
it('a mere READER may still set their OWN value for the same tool', async () => {
|
|
131
|
+
// The gate is on the SHARED value only. One person's own key says nothing
|
|
132
|
+
// about anyone else's, so needing write access to the `.tool` file to type
|
|
133
|
+
// your own credential would lock every reader out of the tools they can see.
|
|
134
|
+
const base = await baseUrlAs(READER);
|
|
135
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/MY_KEY/user`, {
|
|
136
|
+
method: 'PUT',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ value: 'mine-123' }),
|
|
139
|
+
});
|
|
140
|
+
expect(res.status).toBe(201);
|
|
141
|
+
expect(putStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_MY_KEY' }));
|
|
142
|
+
expect(putSharedStatic).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
119
145
|
it('a non-writer is refused (403), even though they can READ the tool', async () => {
|
|
120
146
|
const base = await baseUrlAs(READER);
|
|
121
147
|
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
|
|
@@ -97,7 +97,7 @@ export class DbSecretsVaultService implements ISecretsVaultService {
|
|
|
97
97
|
if (!this.cryptoInstance) {
|
|
98
98
|
if (!this.encKey) {
|
|
99
99
|
throw new InvalidSecretError(
|
|
100
|
-
'Secrets require an encryption key — set
|
|
100
|
+
'Secrets require an encryption key — set SECRETS_ENC_KEY.',
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
this.cryptoInstance = new TokenCrypto(this.encKey);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ISecretsVaultService,
|
|
11
11
|
} from './secrets-vault.contract.js';
|
|
12
12
|
import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/tool-manuals.contract.js';
|
|
13
|
+
import type { IConnectionProbeService } from '../connection-probe/connection-probe.contract.js';
|
|
13
14
|
import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
|
|
14
15
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
15
16
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
@@ -21,6 +22,11 @@ export interface SecretsVaultRoutesDeps {
|
|
|
21
22
|
toolManualService: IToolManualService;
|
|
22
23
|
/** Gates who may set a tool's ADMIN (shared) secrets — writers of the `.tool` file. */
|
|
23
24
|
accessControl: IAccessControl;
|
|
25
|
+
/**
|
|
26
|
+
* Verdicts of the last credential PROBE per tool — what makes the difference
|
|
27
|
+
* between "a key is stored" and "the key works" visible to the UI.
|
|
28
|
+
*/
|
|
29
|
+
connectionProbe: IConnectionProbeService;
|
|
24
30
|
/** HMAC secret for signing the OAuth `state` (reuse the connector state secret). */
|
|
25
31
|
stateSecret: string;
|
|
26
32
|
/** Public base URL of THIS backend — builds the OAuth redirect URI. */
|
|
@@ -69,7 +75,7 @@ export function isSafeReturnPath(returnTo: unknown): returnTo is string {
|
|
|
69
75
|
* `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
|
|
70
76
|
*/
|
|
71
77
|
export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
|
|
72
|
-
const { secretsVault, toolManualService, accessControl } = deps;
|
|
78
|
+
const { secretsVault, toolManualService, accessControl, connectionProbe } = deps;
|
|
73
79
|
const router = express.Router();
|
|
74
80
|
// A FUNCTION, not a constant. Routers are constructed at boot, and on a
|
|
75
81
|
// deployment configured through the setup screen the branch model does not
|
|
@@ -361,6 +367,10 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
361
367
|
value: body.value,
|
|
362
368
|
label: body.label,
|
|
363
369
|
});
|
|
370
|
+
// No probe here, and nothing to invalidate: a verdict is never stored, so
|
|
371
|
+
// saving a key cannot leave a stale one behind. The caller follows this
|
|
372
|
+
// with an explicit check, which keeps the save fast and gives the UI a
|
|
373
|
+
// "Testing…" state to show instead of a frozen dialog.
|
|
364
374
|
res.status(201).json({ secret });
|
|
365
375
|
} catch (err) {
|
|
366
376
|
mapError(err, res, 'set user var');
|
|
@@ -504,6 +514,30 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
504
514
|
}
|
|
505
515
|
});
|
|
506
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Probe one tool's credential NOW and return the verdict.
|
|
519
|
+
*
|
|
520
|
+
* A POST because it has an effect on the world — it makes a real
|
|
521
|
+
* authenticated call to the provider — and because browsers and proxies are
|
|
522
|
+
* free to cache a GET, which for a freshness check would defeat the point.
|
|
523
|
+
*
|
|
524
|
+
* A probe outcome is never an HTTP error: a rejected credential is a
|
|
525
|
+
* successful check that found a problem, so it comes back 200 with
|
|
526
|
+
* `status: 'failed'`. Only a tool that can't be found or read 404s.
|
|
527
|
+
*/
|
|
528
|
+
router.post('/secrets/tools/:slug/check', async (req, res) => {
|
|
529
|
+
const userId = req.userId;
|
|
530
|
+
const email = req.userEmail;
|
|
531
|
+
if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
|
|
532
|
+
try {
|
|
533
|
+
const verdict = await connectionProbe.probe(userId, email, req.params.slug);
|
|
534
|
+
if (!verdict) return void res.status(404).json({ error: 'Tool not found' });
|
|
535
|
+
res.json({ verdict });
|
|
536
|
+
} catch (err) {
|
|
537
|
+
mapError(err, res, 'check tool connection');
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
507
541
|
return router;
|
|
508
542
|
}
|
|
509
543
|
|
|
@@ -37,6 +37,17 @@ describe('descriptorsFromMcpJson', () => {
|
|
|
37
37
|
]);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it('refuses an sse server rather than rebuilding it as a transport it is not', () => {
|
|
41
|
+
// The pinned MCP client has no sse transport; emitting `http` for an sse
|
|
42
|
+
// server configures a handshake the server does not speak.
|
|
43
|
+
const out = descriptorsFromMcpJson(
|
|
44
|
+
'GTM',
|
|
45
|
+
JSON.stringify({ mcpServers: { legacy: { type: 'sse', url: 'https://mcp.legacy.example/sse' } } }),
|
|
46
|
+
null,
|
|
47
|
+
);
|
|
48
|
+
expect(out).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
40
51
|
it('merges extension auth over mcp.json literals and carries variables + description', () => {
|
|
41
52
|
const out = descriptorsFromMcpJson(
|
|
42
53
|
'GTM',
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeToolManual } from '../tool-manuals.service.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `healthCheck:` block is a URL this SERVER will fetch with the caller's
|
|
6
|
+
* credential attached, unattended, on every save and re-check. That makes it a
|
|
7
|
+
* second fetch target on the same footing as the manual's own `url`, so it is
|
|
8
|
+
* policed by the same SSRF rule — and held to one extra rule of its own: it may
|
|
9
|
+
* not mutate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const toolFile = (body: string) => `---
|
|
13
|
+
name: acme
|
|
14
|
+
type: http
|
|
15
|
+
url: https://api.acme.test/utcp
|
|
16
|
+
headers:
|
|
17
|
+
Authorization: Bearer \${API_KEY}
|
|
18
|
+
${body}---
|
|
19
|
+
notes
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const parse = (body: string) => normalizeToolManual('acme', 'Plugins/acme.tool', toolFile(body));
|
|
23
|
+
|
|
24
|
+
describe('`.tool` healthCheck', () => {
|
|
25
|
+
it('is absent when nothing is declared — the tool is simply unverifiable', () => {
|
|
26
|
+
expect(parse('').healthCheck).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('inherits the manual\'s headers, so a one-line declaration still authenticates', () => {
|
|
30
|
+
// This is what makes the common case cheap: the credential already lives in
|
|
31
|
+
// `headers`, and re-typing it on the probe would be a chance to get it wrong.
|
|
32
|
+
expect(parse('healthCheck:\n url: https://api.acme.test/me\n').healthCheck).toEqual({
|
|
33
|
+
url: 'https://api.acme.test/me',
|
|
34
|
+
headers: { Authorization: 'Bearer ${API_KEY}' },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps its own headers when it declares them', () => {
|
|
39
|
+
const hc = parse('healthCheck:\n url: https://api.acme.test/me\n headers:\n X-Key: ${API_KEY}\n').healthCheck;
|
|
40
|
+
expect(hc?.headers).toEqual({ 'X-Key': '${API_KEY}' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("an INLINE manual's probe inherits NO top-level headers — execution never sends them", () => {
|
|
44
|
+
// An inline manual's real calls go through each embedded tool's own call
|
|
45
|
+
// template; nothing ever sends the manual's top-level `headers:`. A probe
|
|
46
|
+
// that inherited them proved a request no call makes — `Connected` about
|
|
47
|
+
// the wrong request. An inline probe declares its headers on the
|
|
48
|
+
// healthCheck itself, or sends none.
|
|
49
|
+
const inline = normalizeToolManual(
|
|
50
|
+
'acme',
|
|
51
|
+
'Plugins/acme.tool',
|
|
52
|
+
`---
|
|
53
|
+
name: acme
|
|
54
|
+
type: inline
|
|
55
|
+
headers:
|
|
56
|
+
Authorization: Bearer \${API_KEY}
|
|
57
|
+
healthCheck:
|
|
58
|
+
url: https://api.acme.test/me
|
|
59
|
+
tools: []
|
|
60
|
+
---
|
|
61
|
+
`,
|
|
62
|
+
);
|
|
63
|
+
expect(inline.healthCheck?.headers).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('refuses a method that could mutate', () => {
|
|
67
|
+
// Silently downgrading POST to GET would leave the author believing they had
|
|
68
|
+
// declared something we are not doing.
|
|
69
|
+
expect(() => parse('healthCheck:\n url: https://api.acme.test/me\n method: POST\n')).toThrow(
|
|
70
|
+
/may not mutate/,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses a probe pointed at an internal host', () => {
|
|
75
|
+
expect(() => parse('healthCheck:\n url: http://169.254.169.254/latest/meta-data\n')).toThrow(
|
|
76
|
+
/not allowed/,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('refuses a declaration with no url', () => {
|
|
81
|
+
expect(() => parse('healthCheck:\n method: GET\n')).toThrow(/must have a `url`/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A local-only `.tool` is never fetched by this server, so its probe is exempt
|
|
86
|
+
* from the guard for exactly the same reason its `url` is.
|
|
87
|
+
*/
|
|
88
|
+
it('exempts a local-only tool, as it does for the manual url', () => {
|
|
89
|
+
const local = normalizeToolManual(
|
|
90
|
+
'acme',
|
|
91
|
+
'Plugins/acme.tool',
|
|
92
|
+
`---
|
|
93
|
+
name: acme
|
|
94
|
+
type: http
|
|
95
|
+
remote: false
|
|
96
|
+
url: http://localhost:9000/utcp
|
|
97
|
+
healthCheck:
|
|
98
|
+
url: http://localhost:9000/me
|
|
99
|
+
---
|
|
100
|
+
`,
|
|
101
|
+
);
|
|
102
|
+
expect(local.healthCheck?.url).toBe('http://localhost:9000/me');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -82,6 +82,62 @@ describe('ToolManualService', () => {
|
|
|
82
82
|
expect(list.find((m) => m.name === 'billing')!.type).toBe('http');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test('surfaces a variable referenced ONLY by a health check, and keeps the probe off the summary', async () => {
|
|
86
|
+
// Two halves of the same contract. The probe's `${VAR}` has to reach the
|
|
87
|
+
// secrets UI or nobody can ever fill it in and the tool reports
|
|
88
|
+
// `unverifiable` forever — but the probe's HEADERS must not ride the
|
|
89
|
+
// browser-facing summary, since a `.tool` may write a literal token there.
|
|
90
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
91
|
+
await writeFile(
|
|
92
|
+
join(tools, 'probe.tool'),
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
name: 'probe',
|
|
95
|
+
type: 'http',
|
|
96
|
+
url: 'https://api.example.com/utcp',
|
|
97
|
+
healthCheck: { url: 'https://api.example.com/me', headers: { 'X-Key': '${PROBE_ONLY_KEY}' } },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'probe')!;
|
|
102
|
+
expect(summary.variables?.map((v) => v.name)).toContain('PROBE_ONLY_KEY');
|
|
103
|
+
expect(summary).not.toHaveProperty('healthCheck');
|
|
104
|
+
|
|
105
|
+
// The server still reaches it, through the accessor that never serializes.
|
|
106
|
+
const target = await svc().probeTargetFor('user@x.eu', 'probe');
|
|
107
|
+
expect(target?.healthCheck?.headers).toEqual({ 'X-Key': '${PROBE_ONLY_KEY}' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('builds a call template only for the probe that actually dials one', async () => {
|
|
111
|
+
// The template is dialled by exactly one probe: an `mcp` manual, reachable
|
|
112
|
+
// from this process, that declared no health check. Building it for the
|
|
113
|
+
// others costs a validation pass per probe and warn-logs about a value
|
|
114
|
+
// nothing was going to use.
|
|
115
|
+
const tools = join(root, wsId, KB_DIR, 'Plugins');
|
|
116
|
+
await writeFile(
|
|
117
|
+
join(tools, 'chat.tool'),
|
|
118
|
+
JSON.stringify({ name: 'chat', type: 'mcp', url: 'https://mcp.example.com' }),
|
|
119
|
+
);
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(tools, 'checked.tool'),
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
name: 'checked',
|
|
124
|
+
type: 'mcp',
|
|
125
|
+
url: 'https://mcp.example.com',
|
|
126
|
+
healthCheck: { url: 'https://api.example.com/me' },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const service = svc();
|
|
131
|
+
// Asserted through the template's own type, not `not.toBeNull()`: the
|
|
132
|
+
// optional chain yields `undefined` when the lookup itself fails, and
|
|
133
|
+
// `undefined` is not null — so the weaker form passes on no target at all.
|
|
134
|
+
expect((await service.probeTargetFor('user@x.eu', 'chat'))?.callTemplate?.call_template_type).toBe('mcp');
|
|
135
|
+
// A declared health check wins for every type, so this one never dials it.
|
|
136
|
+
expect((await service.probeTargetFor('user@x.eu', 'checked'))?.callTemplate).toBeNull();
|
|
137
|
+
// An http manual is probed only by what it declares, never by a handshake.
|
|
138
|
+
expect((await service.probeTargetFor('user@x.eu', 'billing'))?.callTemplate).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
85
141
|
test('ACL filters out manuals the user cannot read', async () => {
|
|
86
142
|
const list = await svc(denyBilling).listAccessible('user@x.eu');
|
|
87
143
|
expect(list.map((m) => m.name)).toEqual(['weather']);
|
|
@@ -292,7 +292,19 @@ export function descriptorsFromMcpJson(
|
|
|
292
292
|
continue;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
if (raw.type === '
|
|
295
|
+
if (raw.type === 'sse') {
|
|
296
|
+
// The pinned `@utcp/mcp` speaks `stdio` and streamable `http` — there
|
|
297
|
+
// is no sse transport in its schema, so a template claiming one either
|
|
298
|
+
// fails validation or, worse, dials a handshake the server does not
|
|
299
|
+
// speak. Refusing here names the fix; silently rebuilding as http used
|
|
300
|
+
// to configure exactly that wrong handshake.
|
|
301
|
+
console.warn(
|
|
302
|
+
`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: the MCP client has no \`sse\` transport — declare the server as \`streamable-http\` if it supports it.`,
|
|
303
|
+
);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (raw.type === 'streamable-http') {
|
|
296
308
|
if (typeof raw.url !== 'string' || raw.url.length === 0) {
|
|
297
309
|
console.warn(`[tool-manuals] skipping mcp server "${name}" in ${mcpJsonPath}: no url.`);
|
|
298
310
|
continue;
|
|
@@ -174,10 +174,19 @@ export class McpServerEditService {
|
|
|
174
174
|
const servers = mcp.mcpServers as Record<string, unknown>;
|
|
175
175
|
if (!(name in servers)) throw new McpServerEditError('No such server.', 404);
|
|
176
176
|
|
|
177
|
-
if (write.transport !== 'streamable-http' && write.transport !== '
|
|
178
|
-
// Persisting
|
|
179
|
-
//
|
|
180
|
-
|
|
177
|
+
if (write.transport !== 'streamable-http' && write.transport !== 'stdio') {
|
|
178
|
+
// Persisting a transport discovery refuses would save an entry that
|
|
179
|
+
// vanishes from the catalog the moment it is written — an unusable
|
|
180
|
+
// server with no error at the moment it was made. `sse` is refused BY
|
|
181
|
+
// NAME with the fix spelled out, because the pinned MCP client has no
|
|
182
|
+
// sse transport; an EXISTING sse entry stays readable here, so it can
|
|
183
|
+
// be edited to `streamable-http` rather than being stranded.
|
|
184
|
+
throw new McpServerEditError(
|
|
185
|
+
write.transport === 'sse'
|
|
186
|
+
? 'The MCP client has no `sse` transport — declare the server as `streamable-http` if it supports it.'
|
|
187
|
+
: `Unknown transport "${String(write.transport)}".`,
|
|
188
|
+
422,
|
|
189
|
+
);
|
|
181
190
|
}
|
|
182
191
|
// The EFFECTIVE value of every field, PATCH-over-stored (see
|
|
183
192
|
// McpServerWrite): `undefined` keeps what the files already say, a
|
|
@@ -88,6 +88,57 @@ export interface ToolVariable {
|
|
|
88
88
|
oauth?: ToolVariableOAuth;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* An optional cheap, read-only call that proves a credential actually WORKS.
|
|
93
|
+
*
|
|
94
|
+
* A `type: mcp` manual needs none — its handshake is authenticated, so merely
|
|
95
|
+
* connecting already tests the token. `http` and `inline` manuals have no such
|
|
96
|
+
* moment: registering an `http` manual fetches its DESCRIPTION, which providers
|
|
97
|
+
* usually serve publicly, so a wrong key sails through; an `inline` manual's
|
|
98
|
+
* discovery template points at Bevel's own API and never touches the provider
|
|
99
|
+
* at all. Without a declaration there is genuinely nothing to call, and the
|
|
100
|
+
* platform reports the connection as unverified rather than guessing.
|
|
101
|
+
*
|
|
102
|
+
* NOT inferred from the manual's own tools. Picking one automatically would
|
|
103
|
+
* mean calling an arbitrary tool with invented arguments — a probe that could
|
|
104
|
+
* send a message or create a record is worse than no probe. The author names a
|
|
105
|
+
* safe endpoint or the tool stays unverified.
|
|
106
|
+
*
|
|
107
|
+
* `${VAR}` refs resolve from the caller's vault exactly as they do in `url` and
|
|
108
|
+
* `headers`, which is the whole point: the probe must carry the same credential
|
|
109
|
+
* the real calls carry. Any 2xx is a pass; 401/403 is a rejection; anything
|
|
110
|
+
* else is treated as the provider being unwell rather than the key being wrong.
|
|
111
|
+
*/
|
|
112
|
+
export interface ToolHealthCheck {
|
|
113
|
+
/** Absolute URL to call. May contain `${VAR}` refs; SSRF-checked like `url`. */
|
|
114
|
+
url: string;
|
|
115
|
+
/** Default `GET`. A health check may not mutate, so nothing else is allowed. */
|
|
116
|
+
method?: 'GET';
|
|
117
|
+
/** Defaults to the manual's own `headers` — usually where the credential is. */
|
|
118
|
+
headers?: Record<string, string>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One manual, reduced to exactly what testing its credential requires.
|
|
123
|
+
*
|
|
124
|
+
* Server-internal, like {@link ToolHealthCheck} and for the same reason: both
|
|
125
|
+
* `healthCheck.headers` and `callTemplate` can carry a literal token.
|
|
126
|
+
*/
|
|
127
|
+
export interface ToolProbeTarget {
|
|
128
|
+
name: string;
|
|
129
|
+
type: ToolManualType;
|
|
130
|
+
/** `false` for a local-only tool this server is not the one that can reach it. */
|
|
131
|
+
remote?: boolean;
|
|
132
|
+
/** The probe the `.tool` declares, if any. */
|
|
133
|
+
healthCheck?: ToolHealthCheck;
|
|
134
|
+
/**
|
|
135
|
+
* The validated UTCP call template, or `null` when the manual doesn't produce
|
|
136
|
+
* a valid one. Only the `mcp` probe uses it — its handshake IS the test — but
|
|
137
|
+
* it is built here so the probe never has to walk the catalog again.
|
|
138
|
+
*/
|
|
139
|
+
callTemplate: CallTemplate | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
91
142
|
/**
|
|
92
143
|
* For a `type: mcp` tool, what an admin must do to make the remote server
|
|
93
144
|
* reachable — derived from OAuth auto-discovery, which a non-mcp tool has no
|
|
@@ -140,6 +191,15 @@ export interface ToolManualDescriptorBase {
|
|
|
140
191
|
variables?: ToolVariable[];
|
|
141
192
|
/** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
|
|
142
193
|
setup?: ToolManualSetup;
|
|
194
|
+
/**
|
|
195
|
+
* Optional credential probe — see {@link ToolHealthCheck}. Declared on
|
|
196
|
+
* `http`/`inline` manuals, and honoured on `type: mcp` too, where it
|
|
197
|
+
* OVERRIDES the default handshake probe (a server whose health endpoint is
|
|
198
|
+
* cheaper or more truthful than a full MCP handshake says so here).
|
|
199
|
+
* INTERNAL: lives on the descriptor, never on {@link ToolManualSummary},
|
|
200
|
+
* because it carries `headers`.
|
|
201
|
+
*/
|
|
202
|
+
healthCheck?: ToolHealthCheck;
|
|
143
203
|
}
|
|
144
204
|
|
|
145
205
|
/** The spawn spec of a stdio-declared MCP server (a plugin `mcp.json` entry). */
|
|
@@ -197,6 +257,10 @@ export interface ToolManualSummary {
|
|
|
197
257
|
remote?: boolean;
|
|
198
258
|
/** For `type: mcp`: the admin-facing setup requirement from auto-discovery. */
|
|
199
259
|
setup?: ToolManualSetup;
|
|
260
|
+
// NOTE: no `healthCheck` here, deliberately. This type is serialized straight
|
|
261
|
+
// to the browser by the tool endpoints, and a probe carries `headers` — which
|
|
262
|
+
// a `.tool` author may write as a literal token rather than a `${VAR}` ref.
|
|
263
|
+
// The server reads probe config through `IToolManualService.probeTargetFor`.
|
|
200
264
|
}
|
|
201
265
|
|
|
202
266
|
/** One thing an `inline` manual's embedded tool list says the assistant can do. */
|
|
@@ -269,6 +333,31 @@ export interface IToolManualService {
|
|
|
269
333
|
* a `resolve` reads the shared row or the caller's row.
|
|
270
334
|
*/
|
|
271
335
|
scopeOfVariable(effectiveKey: string): Promise<ToolVariableScope>;
|
|
336
|
+
/**
|
|
337
|
+
* Everything the credential probe needs about one readable manual, resolved
|
|
338
|
+
* in a SINGLE catalog + ACL pass.
|
|
339
|
+
*
|
|
340
|
+
* One accessor rather than three because the probe needs three facts that all
|
|
341
|
+
* come from the same file — is it local-only, does it declare a health check,
|
|
342
|
+
* and what call template would reach it — and asking for them separately made
|
|
343
|
+
* one probe walk the catalog four times, building call templates for every
|
|
344
|
+
* manual in the workspace to use exactly one of them.
|
|
345
|
+
*
|
|
346
|
+
* Deliberately NOT reachable through {@link ToolManualSummary}: that type is
|
|
347
|
+
* serialized straight to the browser by the tool endpoints, and a probe
|
|
348
|
+
* carries `headers` — which a `.tool` author may write as a literal token
|
|
349
|
+
* rather than a `${VAR}` ref. Probe config is internal to the server, so it
|
|
350
|
+
* travels by its own accessor and never rides a public DTO.
|
|
351
|
+
*
|
|
352
|
+
* Addressed by SLUG, which is what the route has: resolving the slug here
|
|
353
|
+
* rather than making the caller map it to a name first is what reduces a
|
|
354
|
+
* probe to one pass. `ToolProbeTarget.name` carries the UTCP namespace back
|
|
355
|
+
* out, since that is what the probe's `${VAR}` lookups are keyed by.
|
|
356
|
+
*
|
|
357
|
+
* `null` when no such manual exists OR the caller can't read it — the two are
|
|
358
|
+
* indistinguishable on purpose, as everywhere else in this contract.
|
|
359
|
+
*/
|
|
360
|
+
probeTargetFor(userEmail: string, slug: string): Promise<ToolProbeTarget | null>;
|
|
272
361
|
/**
|
|
273
362
|
* The per-user (`user`-scoped) variables a manual declares, each with the vault
|
|
274
363
|
* key (`<manual>_<VAR>`) the caller's value is stored under, its bare name, and
|