@mhingston5/conduit 1.1.2 → 1.1.3

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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@mhingston5/conduit",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "description": "A secure Code Mode execution substrate for MCP agents",
6
6
  "main": "index.js",
7
+ "bin": {
8
+ "conduit": "./dist/index.js"
9
+ },
7
10
  "publishConfig": {
8
11
  "access": "public"
9
12
  },
@@ -47,11 +50,13 @@
47
50
  "ajv": "^8.17.1",
48
51
  "ajv-formats": "^3.0.1",
49
52
  "axios": "^1.13.2",
53
+ "commander": "^14.0.2",
50
54
  "dotenv": "^17.2.3",
51
55
  "fastify": "^5.6.2",
52
56
  "isolated-vm": "^6.0.2",
53
57
  "js-yaml": "^4.1.1",
54
58
  "lru-cache": "^11.2.4",
59
+ "open": "^11.0.0",
55
60
  "p-limit": "^7.2.0",
56
61
  "pino": "^10.1.0",
57
62
  "pyodide": "^0.29.0",
@@ -0,0 +1,95 @@
1
+ import Fastify from 'fastify';
2
+ import axios from 'axios';
3
+ import open from 'open';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ export interface AuthOptions {
7
+ authUrl: string;
8
+ tokenUrl: string;
9
+ clientId: string;
10
+ clientSecret: string;
11
+ scopes?: string;
12
+ port?: number;
13
+ }
14
+
15
+ export async function handleAuth(options: AuthOptions) {
16
+ const port = options.port || 3333;
17
+ const redirectUri = `http://localhost:${port}/callback`;
18
+ const state = uuidv4();
19
+
20
+ const fastify = Fastify();
21
+
22
+ return new Promise<void>((resolve, reject) => {
23
+ fastify.get('/callback', async (request, reply) => {
24
+ const { code, state: returnedState, error, error_description } = request.query as any;
25
+
26
+ if (error) {
27
+ reply.send(`Authentication failed: ${error} - ${error_description}`);
28
+ reject(new Error(`OAuth error: ${error}`));
29
+ return;
30
+ }
31
+
32
+ if (returnedState !== state) {
33
+ reply.send('Invalid state parameter');
34
+ reject(new Error('State mismatch'));
35
+ return;
36
+ }
37
+
38
+ try {
39
+ const response = await axios.post(options.tokenUrl, {
40
+ grant_type: 'authorization_code',
41
+ code,
42
+ redirect_uri: redirectUri,
43
+ client_id: options.clientId,
44
+ client_secret: options.clientSecret,
45
+ });
46
+
47
+ const { refresh_token, access_token } = response.data;
48
+
49
+ console.log('\n--- Authentication Successful ---\n');
50
+ console.log('Use these values in your conduit.yaml:\n');
51
+ console.log('credentials:');
52
+ console.log(' type: oauth2');
53
+ console.log(` clientId: ${options.clientId}`);
54
+ console.log(` clientSecret: ${options.clientSecret}`);
55
+ console.log(` tokenUrl: "${options.tokenUrl}"`);
56
+ console.log(` refreshToken: "${refresh_token || 'N/A (No refresh token returned)'}"`);
57
+
58
+ if (!refresh_token) {
59
+ console.log('\nWarning: No refresh token was returned. Ensure your app has "offline_access" scope or similar.');
60
+ }
61
+
62
+ console.log('\nRaw response data:', JSON.stringify(response.data, null, 2));
63
+
64
+ reply.send('Authentication successful! You can close this window and return to the terminal.');
65
+ resolve();
66
+ } catch (err: any) {
67
+ const msg = err.response?.data?.error_description || err.response?.data?.error || err.message;
68
+ reply.send(`Failed to exchange code for token: ${msg}`);
69
+ reject(new Error(`Token exchange failed: ${msg}`));
70
+ } finally {
71
+ setTimeout(() => fastify.close(), 1000);
72
+ }
73
+ });
74
+
75
+ fastify.listen({ port: port, host: '127.0.0.1' }, async (err) => {
76
+ if (err) {
77
+ reject(err);
78
+ return;
79
+ }
80
+
81
+ const authUrl = new URL(options.authUrl);
82
+ authUrl.searchParams.append('client_id', options.clientId);
83
+ authUrl.searchParams.append('redirect_uri', redirectUri);
84
+ authUrl.searchParams.append('response_type', 'code');
85
+ authUrl.searchParams.append('state', state);
86
+ if (options.scopes) {
87
+ authUrl.searchParams.append('scope', options.scopes);
88
+ }
89
+
90
+ console.log(`Opening browser to: ${authUrl.toString()}`);
91
+ console.log('Waiting for callback...');
92
+ await open(authUrl.toString());
93
+ });
94
+ });
95
+ }
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import dotenv from 'dotenv';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import crypto from 'node:crypto';
5
6
  import yaml from 'js-yaml';
6
7
 
7
8
  // Silence dotenv logging
@@ -22,12 +23,14 @@ export const ResourceLimitsSchema = z.object({
22
23
  });
23
24
 
24
25
  export const UpstreamCredentialsSchema = z.object({
25
- type: z.enum(['oauth2', 'apikey']), // Add other types as needed
26
+ type: z.enum(['oauth2', 'apiKey', 'bearer']), // Align with AuthType
26
27
  clientId: z.string().optional(),
27
28
  clientSecret: z.string().optional(),
28
29
  tokenUrl: z.string().optional(),
30
+ refreshToken: z.string().optional(),
29
31
  scopes: z.array(z.string()).optional(),
30
32
  apiKey: z.string().optional(),
33
+ bearerToken: z.string().optional(),
31
34
  headerName: z.string().optional(),
32
35
  });
33
36
 
@@ -63,7 +66,7 @@ export const ConfigSchema = z.object({
63
66
  secretRedactionPatterns: z.array(z.string()).default([
64
67
  '[A-Za-z0-9-_]{20,}', // Default pattern from spec
65
68
  ]),
66
- ipcBearerToken: z.string().optional().default(() => Math.random().toString(36).substring(7)),
69
+ ipcBearerToken: z.string().optional().default(() => crypto.randomUUID()),
67
70
  maxConcurrent: z.number().default(10),
68
71
  denoMaxPoolSize: z.number().default(10),
69
72
  pyodideMaxPoolSize: z.number().default(3),
@@ -209,6 +209,7 @@ export class RequestController {
209
209
  }
210
210
 
211
211
  private async handleCallTool(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
212
+ if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
212
213
  const { name, arguments: toolArgs } = params;
213
214
 
214
215
  // Route built-in tools to their specific handlers
@@ -226,6 +227,7 @@ export class RequestController {
226
227
  }
227
228
 
228
229
  private async handleExecuteTypeScript(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
230
+ if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
229
231
  const { code, limits, allowedTools } = params;
230
232
 
231
233
  if (Array.isArray(allowedTools)) {
@@ -250,6 +252,7 @@ export class RequestController {
250
252
  }
251
253
 
252
254
  private async handleExecutePython(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
255
+ if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
253
256
  const { code, limits, allowedTools } = params;
254
257
 
255
258
  if (Array.isArray(allowedTools)) {
@@ -299,6 +302,7 @@ export class RequestController {
299
302
  }
300
303
 
301
304
  private async handleExecuteIsolate(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
305
+ if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
302
306
  const { code, limits, allowedTools } = params;
303
307
 
304
308
  if (Array.isArray(allowedTools)) {
@@ -9,11 +9,11 @@ export type { Session };
9
9
 
10
10
  export class SecurityService implements IUrlValidator {
11
11
  private logger: Logger;
12
- private ipcToken: string;
12
+ private ipcToken: string | undefined;
13
13
  private networkPolicy: NetworkPolicyService;
14
14
  private sessionManager: SessionManager;
15
15
 
16
- constructor(logger: Logger, ipcToken: string) {
16
+ constructor(logger: Logger, ipcToken: string | undefined) {
17
17
  this.logger = logger;
18
18
  this.ipcToken = ipcToken;
19
19
  this.networkPolicy = new NetworkPolicyService(logger);
@@ -67,7 +67,7 @@ export class SecurityService implements IUrlValidator {
67
67
  }
68
68
 
69
69
 
70
- getIpcToken(): string {
70
+ getIpcToken(): string | undefined {
71
71
  return this.ipcToken;
72
72
  }
73
73
  }
@@ -7,12 +7,10 @@ export interface UpstreamCredentials {
7
7
  type: AuthType;
8
8
  apiKey?: string;
9
9
  bearerToken?: string;
10
- oauth2?: {
11
- clientId: string;
12
- clientSecret: string;
13
- tokenUrl: string;
14
- refreshToken: string;
15
- };
10
+ clientId?: string;
11
+ clientSecret?: string;
12
+ tokenUrl?: string;
13
+ refreshToken?: string;
16
14
  }
17
15
 
18
16
  interface CachedToken {
@@ -45,10 +43,11 @@ export class AuthService {
45
43
  }
46
44
 
47
45
  private async getOAuth2Token(creds: UpstreamCredentials): Promise<string> {
48
- if (!creds.oauth2) throw new Error('OAuth2 credentials missing');
46
+ if (!creds.tokenUrl || !creds.clientId) {
47
+ throw new Error('OAuth2 credentials missing required fields (tokenUrl, clientId)');
48
+ }
49
49
 
50
- const { oauth2 } = creds;
51
- const cacheKey = `${oauth2.clientId}:${oauth2.tokenUrl}`;
50
+ const cacheKey = `${creds.clientId}:${creds.tokenUrl}`;
52
51
 
53
52
  // Check cache first (with 30s buffer)
54
53
  const cached = this.tokenCache.get(cacheKey);
@@ -74,17 +73,18 @@ export class AuthService {
74
73
  }
75
74
 
76
75
  private async doRefresh(creds: UpstreamCredentials, cacheKey: string): Promise<string> {
77
- const { oauth2 } = creds;
78
- if (!oauth2) throw new Error('OAuth2 credentials missing');
76
+ if (!creds.tokenUrl || !creds.refreshToken || !creds.clientId || !creds.clientSecret) {
77
+ throw new Error('OAuth2 credentials missing required fields for refresh');
78
+ }
79
79
 
80
- this.logger.info('Refreshing OAuth2 token');
80
+ this.logger.info({ tokenUrl: creds.tokenUrl, clientId: creds.clientId }, 'Refreshing OAuth2 token');
81
81
 
82
82
  try {
83
- const response = await axios.post(oauth2.tokenUrl, {
83
+ const response = await axios.post(creds.tokenUrl, {
84
84
  grant_type: 'refresh_token',
85
- refresh_token: oauth2.refreshToken,
86
- client_id: oauth2.clientId,
87
- client_secret: oauth2.clientSecret,
85
+ refresh_token: creds.refreshToken,
86
+ client_id: creds.clientId,
87
+ client_secret: creds.clientSecret,
88
88
  });
89
89
 
90
90
  const { access_token, expires_in } = response.data;
@@ -97,8 +97,9 @@ export class AuthService {
97
97
 
98
98
  return `Bearer ${access_token}`;
99
99
  } catch (err: any) {
100
- this.logger.error({ err: err.message }, 'Failed to refresh OAuth2 token');
101
- throw new Error(`OAuth2 refresh failed: ${err.message}`);
100
+ const errorMsg = err.response?.data?.error_description || err.response?.data?.error || err.message;
101
+ this.logger.error({ err: errorMsg }, 'Failed to refresh OAuth2 token');
102
+ throw new Error(`OAuth2 refresh failed: ${errorMsg}`);
102
103
  }
103
104
  }
104
105
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Command } from 'commander';
1
2
  import { ConfigService } from './core/config.service.js';
2
3
  import { createLogger, loggerStorage } from './core/logger.js';
3
4
  import { SocketTransport } from './transport/socket.transport.js';
@@ -14,10 +15,56 @@ import { IsolateExecutor } from './executors/isolate.executor.js';
14
15
  import { ExecutorRegistry } from './core/registries/executor.registry.js';
15
16
  import { ExecutionService } from './core/execution.service.js';
16
17
  import { buildDefaultMiddleware } from './core/middleware/middleware.builder.js';
17
- async function main() {
18
- // console.error('DEBUG: Starting Conduit main...');
18
+ import { handleAuth } from './auth.cmd.js';
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name('conduit')
24
+ .description('A secure Code Mode execution substrate for MCP agents')
25
+ .version('1.0.0');
26
+
27
+ program
28
+ .command('serve', { isDefault: true })
29
+ .description('Start the Conduit server')
30
+ .option('--stdio', 'Use stdio transport')
31
+ .action(async (options) => {
32
+ try {
33
+ await startServer();
34
+ } catch (err) {
35
+ console.error('Failed to start Conduit:', err);
36
+ process.exit(1);
37
+ }
38
+ });
39
+
40
+ program
41
+ .command('auth')
42
+ .description('Help set up OAuth for an upstream MCP server')
43
+ .requiredOption('--client-id <id>', 'OAuth Client ID')
44
+ .requiredOption('--client-secret <secret>', 'OAuth Client Secret')
45
+ .requiredOption('--auth-url <url>', 'OAuth Authorization URL')
46
+ .requiredOption('--token-url <url>', 'OAuth Token URL')
47
+ .option('--scopes <scopes>', 'OAuth Scopes (comma separated)')
48
+ .option('--port <port>', 'Port for the local callback server', '3333')
49
+ .action(async (options) => {
50
+ try {
51
+ await handleAuth({
52
+ clientId: options.clientId,
53
+ clientSecret: options.clientSecret,
54
+ authUrl: options.authUrl,
55
+ tokenUrl: options.tokenUrl,
56
+ scopes: options.scopes,
57
+ port: parseInt(options.port, 10),
58
+ });
59
+ console.log('\nSuccess! Configuration generated.');
60
+ } catch (err: any) {
61
+ console.error('Authentication helper failed:', err.message);
62
+ process.exit(1);
63
+ }
64
+ });
65
+
66
+ async function startServer() {
19
67
  const configService = new ConfigService();
20
- // console.error('DEBUG: Config loaded');
21
68
  const logger = createLogger(configService);
22
69
 
23
70
  const otelService = new OtelService(logger);
@@ -78,7 +125,7 @@ async function main() {
78
125
  const port = configService.get('port');
79
126
  address = await transport.listen({ port });
80
127
  }
81
- executionService.ipcAddress = address; // Update IPC address on ExecutionService instead of RequestController
128
+ executionService.ipcAddress = address;
82
129
 
83
130
  // Pre-warm workers
84
131
  await requestController.warmup();
@@ -102,7 +149,4 @@ async function main() {
102
149
  });
103
150
  }
104
151
 
105
- main().catch((err) => {
106
- console.error('Failed to start Conduit:', err);
107
- process.exit(1);
108
- });
152
+ program.parse(process.argv);
@@ -1,4 +1,5 @@
1
1
  import net from 'node:net';
2
+ import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { Logger } from 'pino';
@@ -46,9 +47,13 @@ export class SocketTransport {
46
47
 
47
48
  // Cleanup existing socket if needed (unlikely on Windows, but good for Unix)
48
49
  if (os.platform() !== 'win32' && path.isAbsolute(socketPath)) {
49
- // We rely on caller or deployment to clean up, or error out.
50
- // Trying to unlink here might be dangerous if we don't own it.
51
- // But strictly, we should just listen.
50
+ try {
51
+ fs.unlinkSync(socketPath);
52
+ } catch (error: any) {
53
+ if (error.code !== 'ENOENT') {
54
+ this.logger.warn({ err: error, socketPath }, 'Failed to unlink socket before binding');
55
+ }
56
+ }
52
57
  }
53
58
 
54
59
  this.server.listen(socketPath, () => {
@@ -25,13 +25,10 @@ describe('AuthService', () => {
25
25
  it('should refresh OAuth2 token when expired', async () => {
26
26
  const creds: any = {
27
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
- },
28
+ clientId: 'id',
29
+ clientSecret: 'secret',
30
+ tokenUrl: 'http://token',
31
+ refreshToken: 'refresh',
35
32
  };
36
33
 
37
34
  (axios.post as any).mockResolvedValue({
@@ -50,12 +47,10 @@ describe('AuthService', () => {
50
47
  // First call - will trigger refresh
51
48
  const creds: any = {
52
49
  type: 'oauth2',
53
- oauth2: {
54
- clientId: 'id',
55
- clientSecret: 'secret',
56
- tokenUrl: 'http://token',
57
- refreshToken: 'refresh',
58
- },
50
+ clientId: 'id',
51
+ clientSecret: 'secret',
52
+ tokenUrl: 'http://token',
53
+ refreshToken: 'refresh',
59
54
  };
60
55
 
61
56
  (axios.post as any).mockResolvedValue({
@@ -67,4 +67,35 @@ describe('ConfigService', () => {
67
67
  existsSpy.mockRestore();
68
68
  readSpy.mockRestore();
69
69
  });
70
+
71
+ it('should parse OAuth2 credentials correctly', () => {
72
+ const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((p: any) => p.endsWith('conduit.test.yaml'));
73
+ const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(`
74
+ upstreams:
75
+ - id: test-oauth
76
+ type: http
77
+ url: http://upstream
78
+ credentials:
79
+ type: oauth2
80
+ clientId: my-id
81
+ clientSecret: my-secret
82
+ tokenUrl: http://token
83
+ refreshToken: my-refresh
84
+ `);
85
+
86
+ vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
87
+ const configService = new ConfigService();
88
+ const upstreams = configService.get('upstreams');
89
+ expect(upstreams).toHaveLength(1);
90
+ expect(upstreams![0].credentials).toEqual({
91
+ type: 'oauth2',
92
+ clientId: 'my-id',
93
+ clientSecret: 'my-secret',
94
+ tokenUrl: 'http://token',
95
+ refreshToken: 'my-refresh'
96
+ });
97
+
98
+ existsSpy.mockRestore();
99
+ readSpy.mockRestore();
100
+ });
70
101
  });