@mhingston5/conduit 1.0.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/.env.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`Asset Integrity (Golden Tests) > should match Isolate SDK snapshot 1`] = `
|
|
4
|
+
"// Generated SDK for isolated-vm
|
|
5
|
+
const __allowedTools = ["test__*","github__*"];
|
|
6
|
+
const tools = {
|
|
7
|
+
test: {
|
|
8
|
+
async hello(args) {
|
|
9
|
+
const resStr = await __callTool("test__hello", JSON.stringify(args || {}));
|
|
10
|
+
return JSON.parse(resStr);
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
github: {
|
|
14
|
+
async create_issue(args) {
|
|
15
|
+
const resStr = await __callTool("github__create_issue", JSON.stringify(args || {}));
|
|
16
|
+
return JSON.parse(resStr);
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async $raw(name, args) {
|
|
20
|
+
const normalized = name.replace(/\\./g, '__');
|
|
21
|
+
if (__allowedTools) {
|
|
22
|
+
const allowed = __allowedTools.some(p => {
|
|
23
|
+
if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));
|
|
24
|
+
return normalized === p;
|
|
25
|
+
});
|
|
26
|
+
if (!allowed) throw new Error(\`Tool \${name} is not in the allowlist\`);
|
|
27
|
+
}
|
|
28
|
+
const resStr = await __callTool(normalized, JSON.stringify(args || {}));
|
|
29
|
+
return JSON.parse(resStr);
|
|
30
|
+
},
|
|
31
|
+
};"
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
exports[`Asset Integrity (Golden Tests) > should match Python SDK snapshot 1`] = `
|
|
35
|
+
"# Generated SDK - Do not edit
|
|
36
|
+
_allowed_tools = ["test__*","github__*"]
|
|
37
|
+
|
|
38
|
+
class _ToolNamespace:
|
|
39
|
+
def __init__(self, methods):
|
|
40
|
+
for name, fn in methods.items():
|
|
41
|
+
setattr(self, name, fn)
|
|
42
|
+
|
|
43
|
+
class _Tools:
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.test = _ToolNamespace({
|
|
46
|
+
"hello": lambda args, n="test__hello": _internal_call_tool(n, args)
|
|
47
|
+
})
|
|
48
|
+
self.github = _ToolNamespace({
|
|
49
|
+
"create_issue": lambda args, n="github__create_issue": _internal_call_tool(n, args)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
async def raw(self, name, args):
|
|
53
|
+
"""Call a tool by its full name (escape hatch for dynamic/unknown tools)"""
|
|
54
|
+
normalized = name.replace(".", "__")
|
|
55
|
+
if _allowed_tools is not None:
|
|
56
|
+
allowed = any(
|
|
57
|
+
normalized.startswith(p[:-1]) if p.endswith("__*") else normalized == p
|
|
58
|
+
for p in _allowed_tools
|
|
59
|
+
)
|
|
60
|
+
if not allowed:
|
|
61
|
+
raise PermissionError(f"Tool {name} is not in the allowlist")
|
|
62
|
+
return await _internal_call_tool(normalized, args)
|
|
63
|
+
|
|
64
|
+
tools = _Tools()"
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
exports[`Asset Integrity (Golden Tests) > should match TypeScript SDK snapshot 1`] = `
|
|
68
|
+
"// Generated SDK - Do not edit
|
|
69
|
+
const __allowedTools = ["test__*","github__*"];
|
|
70
|
+
const tools = {
|
|
71
|
+
test: {
|
|
72
|
+
/** Returns a greeting */
|
|
73
|
+
async hello(args) {
|
|
74
|
+
return await __internalCallTool("test__hello", args);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
github: {
|
|
78
|
+
/** Creates a GitHub issue */
|
|
79
|
+
async create_issue(args) {
|
|
80
|
+
return await __internalCallTool("github__create_issue", args);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
/** Call a tool by its full name (escape hatch for dynamic/unknown tools) */
|
|
84
|
+
async $raw(name, args) {
|
|
85
|
+
const normalized = name.replace(/\\./g, '__');
|
|
86
|
+
if (__allowedTools) {
|
|
87
|
+
const allowed = __allowedTools.some(p => {
|
|
88
|
+
if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));
|
|
89
|
+
return normalized === p;
|
|
90
|
+
});
|
|
91
|
+
if (!allowed) throw new Error(\`Tool \${name} is not in the allowlist\`);
|
|
92
|
+
}
|
|
93
|
+
return await __internalCallTool(normalized, args);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
(globalThis as any).tools = tools;"
|
|
97
|
+
`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SDKGenerator, toToolBinding } from '../src/sdk/index.js';
|
|
3
|
+
import { ToolSchema } from '../src/gateway/schema.cache.js';
|
|
4
|
+
|
|
5
|
+
describe('Asset Integrity (Golden Tests)', () => {
|
|
6
|
+
const generator = new SDKGenerator();
|
|
7
|
+
|
|
8
|
+
const sampleTools: ToolSchema[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'test__hello',
|
|
11
|
+
description: 'Returns a greeting',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
name: { type: 'string' }
|
|
16
|
+
},
|
|
17
|
+
required: ['name']
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'github__create_issue',
|
|
22
|
+
description: 'Creates a GitHub issue',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
title: { type: 'string' },
|
|
27
|
+
body: { type: 'string' }
|
|
28
|
+
},
|
|
29
|
+
required: ['title']
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const bindings = sampleTools.map(t => toToolBinding(t.name, t.inputSchema, t.description));
|
|
35
|
+
|
|
36
|
+
it('should match TypeScript SDK snapshot', () => {
|
|
37
|
+
const sdk = generator.generateTypeScript(bindings, ['test__*', 'github__*']);
|
|
38
|
+
expect(sdk).toMatchSnapshot();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should match Python SDK snapshot', () => {
|
|
42
|
+
const sdk = generator.generatePython(bindings, ['test__*', 'github__*']);
|
|
43
|
+
expect(sdk).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should match Isolate SDK snapshot', () => {
|
|
47
|
+
const sdk = generator.generateIsolateSDK(bindings, ['test__*', 'github__*']);
|
|
48
|
+
expect(sdk).toMatchSnapshot();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { AuthService } from '../src/gateway/auth.service.js';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
|
|
6
|
+
vi.mock('axios');
|
|
7
|
+
const logger = pino({ level: 'silent' });
|
|
8
|
+
|
|
9
|
+
describe('AuthService', () => {
|
|
10
|
+
let authService: AuthService;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
authService = new AuthService(logger);
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return API key header', async () => {
|
|
18
|
+
const headers = await authService.getAuthHeaders({
|
|
19
|
+
type: 'apiKey',
|
|
20
|
+
apiKey: 'test-key',
|
|
21
|
+
});
|
|
22
|
+
expect(headers['X-API-Key']).toBe('test-key');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should refresh OAuth2 token when expired', async () => {
|
|
26
|
+
const creds: any = {
|
|
27
|
+
type: 'oauth2',
|
|
28
|
+
oauth2: {
|
|
29
|
+
clientId: 'id',
|
|
30
|
+
clientSecret: 'secret',
|
|
31
|
+
tokenUrl: 'http://token',
|
|
32
|
+
refreshToken: 'refresh',
|
|
33
|
+
expiresAt: Date.now() - 1000, // Expired
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
(axios.post as any).mockResolvedValue({
|
|
38
|
+
data: {
|
|
39
|
+
access_token: 'new-access',
|
|
40
|
+
expires_in: 3600,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const headers = await authService.getAuthHeaders(creds);
|
|
45
|
+
expect(headers['Authorization']).toBe('Bearer new-access');
|
|
46
|
+
expect(axios.post).toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should reuse OAuth2 token if not expired', async () => {
|
|
50
|
+
// First call - will trigger refresh
|
|
51
|
+
const creds: any = {
|
|
52
|
+
type: 'oauth2',
|
|
53
|
+
oauth2: {
|
|
54
|
+
clientId: 'id',
|
|
55
|
+
clientSecret: 'secret',
|
|
56
|
+
tokenUrl: 'http://token',
|
|
57
|
+
refreshToken: 'refresh',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
(axios.post as any).mockResolvedValue({
|
|
62
|
+
data: {
|
|
63
|
+
access_token: 'cached-access',
|
|
64
|
+
expires_in: 3600,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// First call fetches the token
|
|
69
|
+
const headers1 = await authService.getAuthHeaders(creds);
|
|
70
|
+
expect(headers1['Authorization']).toBe('Bearer cached-access');
|
|
71
|
+
expect(axios.post).toHaveBeenCalledTimes(1);
|
|
72
|
+
|
|
73
|
+
// Second call should reuse cached token (no additional post)
|
|
74
|
+
const headers2 = await authService.getAuthHeaders(creds);
|
|
75
|
+
expect(headers2['Authorization']).toBe('Bearer cached-access');
|
|
76
|
+
expect(axios.post).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
5
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
6
|
+
import { SDKGenerator } from '../src/sdk/sdk-generator.js';
|
|
7
|
+
import { createLogger } from '../src/core/logger.js';
|
|
8
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
9
|
+
|
|
10
|
+
describe('ExecutionService (Code Mode Lite)', () => {
|
|
11
|
+
let executionService: ExecutionService;
|
|
12
|
+
let gatewayService: any;
|
|
13
|
+
let logger: any;
|
|
14
|
+
let executorRegistry: any;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
logger = createLogger(new ConfigService());
|
|
18
|
+
|
|
19
|
+
gatewayService = {
|
|
20
|
+
listToolPackages: vi.fn(),
|
|
21
|
+
listToolStubs: vi.fn(),
|
|
22
|
+
discoverTools: vi.fn(), // Should NOT be called
|
|
23
|
+
} as any;
|
|
24
|
+
|
|
25
|
+
const policyService = new PolicyService();
|
|
26
|
+
|
|
27
|
+
const securityService = {
|
|
28
|
+
validateCode: vi.fn().mockReturnValue({ valid: true }),
|
|
29
|
+
createSession: vi.fn().mockReturnValue('token'),
|
|
30
|
+
invalidateSession: vi.fn(),
|
|
31
|
+
} as any;
|
|
32
|
+
|
|
33
|
+
executorRegistry = {
|
|
34
|
+
has: vi.fn().mockReturnValue(true),
|
|
35
|
+
get: vi.fn().mockReturnValue({
|
|
36
|
+
execute: vi.fn().mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 })
|
|
37
|
+
}),
|
|
38
|
+
shutdownAll: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
executionService = new ExecutionService(
|
|
42
|
+
logger,
|
|
43
|
+
{ maxMemoryMb: 128, timeoutMs: 1000 } as any,
|
|
44
|
+
gatewayService,
|
|
45
|
+
securityService,
|
|
46
|
+
executorRegistry
|
|
47
|
+
);
|
|
48
|
+
executionService.ipcAddress = 'test-sock';
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should use listToolPackages and listToolStubs instead of discoverTools for TypeScript execution', async () => {
|
|
52
|
+
const context = new ExecutionContext({ logger });
|
|
53
|
+
|
|
54
|
+
gatewayService.listToolPackages.mockResolvedValue([
|
|
55
|
+
{ id: 'pkg1' }
|
|
56
|
+
]);
|
|
57
|
+
gatewayService.listToolStubs.mockResolvedValue([
|
|
58
|
+
{ id: 'pkg1__tool1', name: 'tool1', description: 'desc' }
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
await executionService.executeTypeScript('console.log("hi")', {}, context);
|
|
62
|
+
|
|
63
|
+
expect(gatewayService.discoverTools).not.toHaveBeenCalled();
|
|
64
|
+
expect(gatewayService.listToolPackages).toHaveBeenCalled();
|
|
65
|
+
expect(gatewayService.listToolStubs).toHaveBeenCalledWith('pkg1', context);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should use listToolPackages and listToolStubs for Python execution', async () => {
|
|
69
|
+
const context = new ExecutionContext({ logger });
|
|
70
|
+
|
|
71
|
+
gatewayService.listToolPackages.mockResolvedValue([
|
|
72
|
+
{ id: 'pkg1' }
|
|
73
|
+
]);
|
|
74
|
+
gatewayService.listToolStubs.mockResolvedValue([
|
|
75
|
+
{ id: 'pkg1__tool1', name: 'tool1' }
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
await executionService.executePython('print("hi")', {}, context);
|
|
79
|
+
|
|
80
|
+
expect(gatewayService.discoverTools).not.toHaveBeenCalled();
|
|
81
|
+
expect(gatewayService.listToolPackages).toHaveBeenCalled();
|
|
82
|
+
expect(gatewayService.listToolStubs).toHaveBeenCalledWith('pkg1', context);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { UpstreamInfo } from '../src/gateway/upstream.client.js';
|
|
5
|
+
import { createLogger } from '../src/core/logger.js';
|
|
6
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
7
|
+
import { IUrlValidator } from '../src/core/interfaces/url.validator.interface.js';
|
|
8
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
9
|
+
|
|
10
|
+
class MockUrlValidator implements IUrlValidator {
|
|
11
|
+
async validateUrl(url: string) { return { valid: true }; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('GatewayService (Code Mode Lite)', () => {
|
|
15
|
+
let gateway: GatewayService;
|
|
16
|
+
let logger: any;
|
|
17
|
+
let urlValidator: IUrlValidator;
|
|
18
|
+
let policyService: PolicyService;
|
|
19
|
+
|
|
20
|
+
const mockUpstream: UpstreamInfo = {
|
|
21
|
+
id: 'mock-upstream',
|
|
22
|
+
type: 'http',
|
|
23
|
+
url: 'http://localhost:3000/mcp'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
logger = createLogger(new ConfigService());
|
|
28
|
+
urlValidator = new MockUrlValidator();
|
|
29
|
+
policyService = new PolicyService();
|
|
30
|
+
gateway = new GatewayService(logger, urlValidator, policyService);
|
|
31
|
+
gateway.registerUpstream(mockUpstream);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('listToolPackages', () => {
|
|
39
|
+
it('should return registered tool packages', async () => {
|
|
40
|
+
const packages = await gateway.listToolPackages();
|
|
41
|
+
expect(packages).toHaveLength(1);
|
|
42
|
+
expect(packages[0].id).toBe('mock-upstream');
|
|
43
|
+
expect(packages[0].description).toContain('Upstream mock-upstream');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('listToolStubs', () => {
|
|
48
|
+
it('should list tool stubs from upstream', async () => {
|
|
49
|
+
// Mock the upstream client call
|
|
50
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
51
|
+
vi.spyOn(client, 'call').mockResolvedValue({
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id: 1,
|
|
54
|
+
result: {
|
|
55
|
+
tools: [
|
|
56
|
+
{ name: 'op1', description: 'Operation 1', inputSchema: {} },
|
|
57
|
+
{ name: 'op2', description: 'Operation 2', inputSchema: {} }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const context = new ExecutionContext({ logger });
|
|
63
|
+
const stubs = await gateway.listToolStubs('mock-upstream', context);
|
|
64
|
+
|
|
65
|
+
expect(stubs).toHaveLength(2);
|
|
66
|
+
expect(stubs[0]).toMatchObject({
|
|
67
|
+
id: 'mock-upstream__op1',
|
|
68
|
+
name: 'op1',
|
|
69
|
+
description: 'Operation 1'
|
|
70
|
+
});
|
|
71
|
+
expect(stubs[1]).toMatchObject({
|
|
72
|
+
id: 'mock-upstream__op2',
|
|
73
|
+
name: 'op2',
|
|
74
|
+
description: 'Operation 2'
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should filter stubs based on allowlist', async () => {
|
|
79
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
80
|
+
vi.spyOn(client, 'call').mockResolvedValue({
|
|
81
|
+
jsonrpc: '2.0',
|
|
82
|
+
id: 1,
|
|
83
|
+
result: {
|
|
84
|
+
tools: [
|
|
85
|
+
{ name: 'allowed', description: 'Allowed Tool', inputSchema: {} },
|
|
86
|
+
{ name: 'blocked', description: 'Blocked Tool', inputSchema: {} }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const context = new ExecutionContext({ logger });
|
|
92
|
+
// PolicyService expects dot notation for allowlist patterns
|
|
93
|
+
context.allowedTools = ['mock-upstream.allowed'];
|
|
94
|
+
|
|
95
|
+
const stubs = await gateway.listToolStubs('mock-upstream', context);
|
|
96
|
+
expect(stubs).toHaveLength(1);
|
|
97
|
+
expect(stubs[0].name).toBe('allowed');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('getToolSchema', () => {
|
|
102
|
+
it('should return schema for specific tool', async () => {
|
|
103
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
104
|
+
vi.spyOn(client, 'call').mockResolvedValue({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
id: 1,
|
|
107
|
+
result: {
|
|
108
|
+
tools: [
|
|
109
|
+
{ name: 'target', description: 'Target Tool', inputSchema: { type: 'object' } }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const context = new ExecutionContext({ logger });
|
|
115
|
+
// First call triggers discovery (lazy load simulation)
|
|
116
|
+
const schema = await gateway.getToolSchema('mock-upstream__target', context);
|
|
117
|
+
|
|
118
|
+
expect(schema).not.toBeNull();
|
|
119
|
+
expect(schema?.name).toBe('mock-upstream__target'); // Namespaced
|
|
120
|
+
expect(schema?.description).toBe('Target Tool');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should block access if not in allowlist', async () => {
|
|
124
|
+
const context = new ExecutionContext({ logger });
|
|
125
|
+
context.allowedTools = ['other-tool'];
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await gateway.getToolSchema('mock-upstream__target', context);
|
|
129
|
+
expect.fail('Should have thrown error');
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
expect(err.message).toContain('forbidden by allowlist');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should return null if tool does not exist', async () => {
|
|
136
|
+
const client = (gateway as any).clients.get('mock-upstream');
|
|
137
|
+
vi.spyOn(client, 'call').mockResolvedValue({
|
|
138
|
+
jsonrpc: '2.0',
|
|
139
|
+
id: 1,
|
|
140
|
+
result: {
|
|
141
|
+
tools: []
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const context = new ExecutionContext({ logger });
|
|
146
|
+
const schema = await gateway.getToolSchema('mock-upstream__nonexistent', context);
|
|
147
|
+
expect(schema).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ConcurrencyService } from '../src/core/concurrency.service.js';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
|
|
5
|
+
const logger = pino({ level: 'silent' });
|
|
6
|
+
|
|
7
|
+
describe('ConcurrencyService', () => {
|
|
8
|
+
it('should limit concurrent tasks', async () => {
|
|
9
|
+
const service = new ConcurrencyService(logger, { maxConcurrent: 2 });
|
|
10
|
+
let active = 0;
|
|
11
|
+
let maxSeen = 0;
|
|
12
|
+
|
|
13
|
+
const task = async () => {
|
|
14
|
+
active++;
|
|
15
|
+
maxSeen = Math.max(maxSeen, active);
|
|
16
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
17
|
+
active--;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
await Promise.all([
|
|
21
|
+
service.run(task),
|
|
22
|
+
service.run(task),
|
|
23
|
+
service.run(task),
|
|
24
|
+
service.run(task),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
expect(maxSeen).toBe(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should track stats correctly', async () => {
|
|
31
|
+
const service = new ConcurrencyService(logger, { maxConcurrent: 1 });
|
|
32
|
+
|
|
33
|
+
const task1 = service.run(async () => {
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const task2 = service.run(async () => {
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// At this point, task1 is active, task2 is pending
|
|
42
|
+
expect(service.stats.activeCount).toBe(1);
|
|
43
|
+
expect(service.stats.pendingCount).toBe(1);
|
|
44
|
+
|
|
45
|
+
await Promise.all([task1, task2]);
|
|
46
|
+
|
|
47
|
+
expect(service.stats.activeCount).toBe(0);
|
|
48
|
+
expect(service.stats.pendingCount).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ConcurrencyService } from '../src/core/concurrency.service.js';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
|
|
5
|
+
const logger = pino({ level: 'silent' });
|
|
6
|
+
|
|
7
|
+
describe('ConcurrencyService', () => {
|
|
8
|
+
it('should limit concurrent executions', async () => {
|
|
9
|
+
const service = new ConcurrencyService(logger, { maxConcurrent: 2 });
|
|
10
|
+
let activeCount = 0;
|
|
11
|
+
let maxActive = 0;
|
|
12
|
+
|
|
13
|
+
const tasks = Array.from({ length: 5 }, async (_, i) => {
|
|
14
|
+
return service.run(async () => {
|
|
15
|
+
activeCount++;
|
|
16
|
+
maxActive = Math.max(maxActive, activeCount);
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
18
|
+
activeCount--;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await Promise.all(tasks);
|
|
23
|
+
|
|
24
|
+
expect(maxActive).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should handle errors without stucking', async () => {
|
|
28
|
+
const service = new ConcurrencyService(logger, { maxConcurrent: 1 });
|
|
29
|
+
|
|
30
|
+
await expect(service.run(async () => {
|
|
31
|
+
throw new Error('fail');
|
|
32
|
+
})).rejects.toThrow('fail');
|
|
33
|
+
|
|
34
|
+
// Should still be able to run next task
|
|
35
|
+
let ran = false;
|
|
36
|
+
await service.run(async () => {
|
|
37
|
+
ran = true;
|
|
38
|
+
});
|
|
39
|
+
expect(ran).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
describe('ConfigService', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.stubEnv('PORT', '4000');
|
|
9
|
+
vi.stubEnv('NODE_ENV', 'test');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should load config from environment variables', () => {
|
|
13
|
+
const configService = new ConfigService();
|
|
14
|
+
expect(configService.get('port')).toBe(4000);
|
|
15
|
+
expect(configService.get('nodeEnv')).toBe('test');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should use default values when env vars are missing', () => {
|
|
19
|
+
vi.stubEnv('PORT', '');
|
|
20
|
+
const configService = new ConfigService();
|
|
21
|
+
// Since port has a default('3000'), it should be 3000
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should prioritize overrides over env vars', () => {
|
|
25
|
+
const configService = new ConfigService({ port: 5000 as any });
|
|
26
|
+
expect(configService.get('port')).toBe(5000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should throw error on invalid configuration', () => {
|
|
30
|
+
vi.stubEnv('NODE_ENV', 'invalid');
|
|
31
|
+
expect(() => new ConfigService()).toThrow(/Invalid configuration/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should substitute env vars in config file', () => {
|
|
35
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((p: any) => p.endsWith('conduit.test.yaml'));
|
|
36
|
+
const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue("port: ${TEST_PORT}\nmetricsUrl: ${TEST_URL:-http://default}");
|
|
37
|
+
|
|
38
|
+
vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
|
|
39
|
+
vi.stubEnv('TEST_PORT', '6000');
|
|
40
|
+
vi.stubEnv('TEST_URL', 'http://overridden');
|
|
41
|
+
|
|
42
|
+
// Ensure env var doesn't override file config
|
|
43
|
+
delete process.env.PORT;
|
|
44
|
+
|
|
45
|
+
const configService = new ConfigService();
|
|
46
|
+
expect(configService.get('port')).toBe(6000);
|
|
47
|
+
expect(configService.get('metricsUrl')).toBe('http://overridden');
|
|
48
|
+
|
|
49
|
+
existsSpy.mockRestore();
|
|
50
|
+
readSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should use default values in substitution', () => {
|
|
54
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((p: any) => p.endsWith('conduit.test.yaml'));
|
|
55
|
+
const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue("port: ${TEST_PORT:-7000}");
|
|
56
|
+
|
|
57
|
+
vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
|
|
58
|
+
vi.stubEnv('TEST_PORT', '');
|
|
59
|
+
delete process.env.TEST_PORT;
|
|
60
|
+
|
|
61
|
+
// Ensure env var doesn't override file config
|
|
62
|
+
delete process.env.PORT;
|
|
63
|
+
|
|
64
|
+
const configService = new ConfigService();
|
|
65
|
+
expect(configService.get('port')).toBe(7000);
|
|
66
|
+
|
|
67
|
+
existsSpy.mockRestore();
|
|
68
|
+
readSpy.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { startReferenceMCP } from './reference_mcp.js';
|
|
5
|
+
import pino from 'pino';
|
|
6
|
+
|
|
7
|
+
const logger = pino({ level: 'silent' });
|
|
8
|
+
|
|
9
|
+
describe('Contract Test: Conduit vs Reference MCP', () => {
|
|
10
|
+
let gateway: GatewayService;
|
|
11
|
+
let context: ExecutionContext;
|
|
12
|
+
let refServer: any;
|
|
13
|
+
const REF_PORT = 4567;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
refServer = await startReferenceMCP(REF_PORT);
|
|
17
|
+
const securityService = {
|
|
18
|
+
validateUrl: vi.fn().mockReturnValue({ valid: true }),
|
|
19
|
+
} as any;
|
|
20
|
+
gateway = new GatewayService(logger, securityService);
|
|
21
|
+
context = new ExecutionContext({ logger });
|
|
22
|
+
|
|
23
|
+
gateway.registerUpstream({
|
|
24
|
+
id: 'ref',
|
|
25
|
+
url: `http://localhost:${REF_PORT}`,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await refServer.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should successfully discover tools from reference MCP', async () => {
|
|
34
|
+
const tools = await gateway.discoverTools(context);
|
|
35
|
+
expect(tools).toHaveLength(1);
|
|
36
|
+
expect(tools[0].name).toBe('ref__echo');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should successfully call tool on reference MCP', async () => {
|
|
40
|
+
const response = await gateway.callTool('ref__echo', { msg: 'hello' }, context);
|
|
41
|
+
expect(response.result.content[0].text).toContain('{"msg":"hello"}');
|
|
42
|
+
});
|
|
43
|
+
});
|