@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,42 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the absolute path to an asset file, handling both source (dev)
|
|
9
|
+
* and distribution (prod) directory structures.
|
|
10
|
+
*
|
|
11
|
+
* Strategies:
|
|
12
|
+
* 1. ../assets/{filename} (Source: src/core -> src/assets)
|
|
13
|
+
* 2. ./assets/{filename} (Dist: dist/ -> dist/assets, if core is merged or similar)
|
|
14
|
+
* 3. ../../assets/{filename} (Dist: dist/core -> dist/assets)
|
|
15
|
+
* 4. assets/{filename} (Relative to cwd, unlikely but fallback)
|
|
16
|
+
*
|
|
17
|
+
* @param filename The name of the asset file (e.g., 'deno-shim.ts')
|
|
18
|
+
* @returns The absolute path to the asset if found
|
|
19
|
+
* @throws Error if the asset cannot be found
|
|
20
|
+
*/
|
|
21
|
+
export function resolveAssetPath(filename: string): string {
|
|
22
|
+
const candidates = [
|
|
23
|
+
// Source structure: src/core/asset.utils.ts -> src/assets/
|
|
24
|
+
path.resolve(__dirname, '../assets', filename),
|
|
25
|
+
// Dist structure possibility 1: dist/ (flat) with assets/ subdir
|
|
26
|
+
path.resolve(__dirname, './assets', filename),
|
|
27
|
+
// Dist structure possibility 2: dist/core/ -> dist/assets/
|
|
28
|
+
path.resolve(__dirname, '../../assets', filename),
|
|
29
|
+
// Dist structure possibility 3: dist/ -> assets/ (if called from root)
|
|
30
|
+
path.resolve(process.cwd(), 'assets', filename),
|
|
31
|
+
// Dist structure possibility 4: dist/assets/ (from root)
|
|
32
|
+
path.resolve(process.cwd(), 'dist/assets', filename)
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (fs.existsSync(candidate)) {
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error(`Asset not found: ${filename}. Checked paths: ${candidates.join(', ')}`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import pLimit from 'p-limit';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
import { trace } from '@opentelemetry/api';
|
|
4
|
+
import { metrics } from './metrics.service.js';
|
|
5
|
+
|
|
6
|
+
export interface ConcurrencyOptions {
|
|
7
|
+
maxConcurrent: number;
|
|
8
|
+
maxQueueSize?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class QueueFullError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'QueueFullError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ConcurrencyService {
|
|
19
|
+
private limit: ReturnType<typeof pLimit>;
|
|
20
|
+
private logger: Logger;
|
|
21
|
+
private maxQueueSize: number;
|
|
22
|
+
private queueDepthHistogram: any; // Using explicit type locally if needed, or rely on metrics service later. Using direct OTEL for now involves refactor.
|
|
23
|
+
// Let's rely on internal state for rejection and let MetricsService handle reporting if possible, or add it here.
|
|
24
|
+
// simpler: usage of metrics service is better pattern.
|
|
25
|
+
|
|
26
|
+
constructor(logger: Logger, options: ConcurrencyOptions) {
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
this.limit = pLimit(options.maxConcurrent);
|
|
29
|
+
this.maxQueueSize = options.maxQueueSize || 100; // Default to 100
|
|
30
|
+
|
|
31
|
+
metrics.registerQueueLengthProvider(() => this.limit.pendingCount);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
35
|
+
if (this.limit.pendingCount >= this.maxQueueSize) {
|
|
36
|
+
this.logger.warn({ pending: this.limit.pendingCount, max: this.maxQueueSize }, 'Request queue full, rejecting request');
|
|
37
|
+
throw new QueueFullError('Server is too busy, please try again later');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const active = this.limit.activeCount;
|
|
41
|
+
const pending = this.limit.pendingCount;
|
|
42
|
+
|
|
43
|
+
this.logger.debug({ active, pending }, 'Concurrency status before task');
|
|
44
|
+
|
|
45
|
+
// Add attributes to current OTEL span if exists
|
|
46
|
+
const span = trace.getActiveSpan();
|
|
47
|
+
if (span) {
|
|
48
|
+
span.setAttributes({
|
|
49
|
+
'concurrency.active': active,
|
|
50
|
+
'concurrency.pending': pending,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await this.limit(fn);
|
|
56
|
+
} finally {
|
|
57
|
+
this.logger.debug({
|
|
58
|
+
active: this.limit.activeCount,
|
|
59
|
+
pending: this.limit.pendingCount
|
|
60
|
+
}, 'Concurrency status after task');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get stats() {
|
|
65
|
+
return {
|
|
66
|
+
activeCount: this.limit.activeCount,
|
|
67
|
+
pendingCount: this.limit.pendingCount,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
dotenv.config();
|
|
8
|
+
|
|
9
|
+
import { AppConfig } from './interfaces/app.config.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export const ResourceLimitsSchema = z.object({
|
|
13
|
+
timeoutMs: z.number().default(30000),
|
|
14
|
+
memoryLimitMb: z.number().default(256),
|
|
15
|
+
maxOutputBytes: z.number().default(1024 * 1024), // 1MB
|
|
16
|
+
maxLogEntries: z.number().default(10000),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const UpstreamCredentialsSchema = z.object({
|
|
20
|
+
type: z.enum(['oauth2', 'apikey']), // Add other types as needed
|
|
21
|
+
clientId: z.string().optional(),
|
|
22
|
+
clientSecret: z.string().optional(),
|
|
23
|
+
tokenUrl: z.string().optional(),
|
|
24
|
+
scopes: z.array(z.string()).optional(),
|
|
25
|
+
apiKey: z.string().optional(),
|
|
26
|
+
headerName: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const HttpUpstreamSchema = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
type: z.literal('http').optional().default('http'),
|
|
32
|
+
url: z.string(),
|
|
33
|
+
credentials: UpstreamCredentialsSchema.optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const StdioUpstreamSchema = z.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
type: z.literal('stdio'),
|
|
39
|
+
command: z.string(),
|
|
40
|
+
args: z.array(z.string()).optional(),
|
|
41
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const UpstreamInfoSchema = z.union([HttpUpstreamSchema, StdioUpstreamSchema]);
|
|
45
|
+
|
|
46
|
+
export type ResourceLimits = z.infer<typeof ResourceLimitsSchema>;
|
|
47
|
+
|
|
48
|
+
export const ConfigSchema = z.object({
|
|
49
|
+
port: z.union([z.string(), z.number()]).default('3000').transform((v) => Number(v)),
|
|
50
|
+
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
|
|
51
|
+
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
52
|
+
resourceLimits: ResourceLimitsSchema.default({
|
|
53
|
+
timeoutMs: 30000,
|
|
54
|
+
memoryLimitMb: 256,
|
|
55
|
+
maxOutputBytes: 1024 * 1024,
|
|
56
|
+
maxLogEntries: 10000,
|
|
57
|
+
}),
|
|
58
|
+
secretRedactionPatterns: z.array(z.string()).default([
|
|
59
|
+
'[A-Za-z0-9-_]{20,}', // Default pattern from spec
|
|
60
|
+
]),
|
|
61
|
+
ipcBearerToken: z.string().optional().default(() => Math.random().toString(36).substring(7)),
|
|
62
|
+
maxConcurrent: z.number().default(10),
|
|
63
|
+
denoMaxPoolSize: z.number().default(10),
|
|
64
|
+
pyodideMaxPoolSize: z.number().default(3),
|
|
65
|
+
metricsUrl: z.string().default('http://127.0.0.1:9464/metrics'),
|
|
66
|
+
opsPort: z.number().optional(),
|
|
67
|
+
upstreams: z.array(UpstreamInfoSchema).default([]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
71
|
+
|
|
72
|
+
export class ConfigService {
|
|
73
|
+
private config: AppConfig;
|
|
74
|
+
|
|
75
|
+
constructor(overrides: Partial<AppConfig> = {}) {
|
|
76
|
+
const fileConfig = this.loadConfigFile();
|
|
77
|
+
|
|
78
|
+
const envConfig = {
|
|
79
|
+
port: process.env.PORT,
|
|
80
|
+
nodeEnv: process.env.NODE_ENV,
|
|
81
|
+
logLevel: process.env.LOG_LEVEL,
|
|
82
|
+
metricsUrl: process.env.METRICS_URL,
|
|
83
|
+
// upstreams: process.env.UPSTREAMS ? JSON.parse(process.env.UPSTREAMS) : undefined, // Removed per user request
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Remove undefined keys from envConfig
|
|
87
|
+
Object.keys(envConfig).forEach(key => envConfig[key as keyof typeof envConfig] === undefined && delete envConfig[key as keyof typeof envConfig]);
|
|
88
|
+
|
|
89
|
+
const mergedConfig = {
|
|
90
|
+
...fileConfig,
|
|
91
|
+
...envConfig,
|
|
92
|
+
...overrides,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const result = ConfigSchema.safeParse(mergedConfig);
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
const error = result.error.format();
|
|
98
|
+
throw new Error(`Invalid configuration: ${JSON.stringify(error, null, 2)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.config = result.data as AppConfig;
|
|
102
|
+
|
|
103
|
+
// Default opsPort if not set
|
|
104
|
+
if (!this.config.opsPort) {
|
|
105
|
+
this.config.opsPort = this.config.port === 0 ? 0 : this.config.port + 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
|
110
|
+
return this.config[key];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get all(): AppConfig {
|
|
114
|
+
return { ...this.config };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private loadConfigFile(): Partial<AppConfig> {
|
|
118
|
+
const configPath = process.env.CONFIG_FILE ||
|
|
119
|
+
(fs.existsSync(path.resolve(process.cwd(), 'conduit.yaml')) ? 'conduit.yaml' :
|
|
120
|
+
(fs.existsSync(path.resolve(process.cwd(), 'conduit.json')) ? 'conduit.json' : null));
|
|
121
|
+
|
|
122
|
+
if (!configPath) return {};
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const fullPath = path.resolve(process.cwd(), configPath);
|
|
126
|
+
let fileContent = fs.readFileSync(fullPath, 'utf-8');
|
|
127
|
+
|
|
128
|
+
// Env var substitution: ${VAR} or ${VAR:-default}
|
|
129
|
+
fileContent = fileContent.replace(/\$\{([a-zA-Z0-9_]+)(?::-([^}]+))?\}/g, (match, varName, defaultValue) => {
|
|
130
|
+
const value = process.env[varName];
|
|
131
|
+
if (value !== undefined) {
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
return defaultValue !== undefined ? defaultValue : '';
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (configPath.endsWith('.yaml') || configPath.endsWith('.yml')) {
|
|
138
|
+
return yaml.load(fileContent) as Partial<AppConfig>;
|
|
139
|
+
} else {
|
|
140
|
+
return JSON.parse(fileContent);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.warn(`Failed to load config file ${configPath}:`, error);
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
|
|
4
|
+
export interface ExecutionContextOptions {
|
|
5
|
+
tenantId?: string;
|
|
6
|
+
logger: Logger;
|
|
7
|
+
allowedTools?: string[];
|
|
8
|
+
remoteAddress?: string;
|
|
9
|
+
strictValidation?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ExecutionContext {
|
|
13
|
+
public readonly correlationId: string;
|
|
14
|
+
public readonly startTime: number;
|
|
15
|
+
public readonly tenantId?: string;
|
|
16
|
+
public logger: Logger;
|
|
17
|
+
public allowedTools?: string[];
|
|
18
|
+
public readonly remoteAddress?: string;
|
|
19
|
+
public readonly strictValidation: boolean;
|
|
20
|
+
|
|
21
|
+
constructor(options: ExecutionContextOptions) {
|
|
22
|
+
this.correlationId = uuidv4();
|
|
23
|
+
this.startTime = Date.now();
|
|
24
|
+
this.tenantId = options.tenantId;
|
|
25
|
+
this.allowedTools = options.allowedTools;
|
|
26
|
+
this.remoteAddress = options.remoteAddress;
|
|
27
|
+
this.strictValidation = options.strictValidation ?? false;
|
|
28
|
+
this.logger = options.logger.child({
|
|
29
|
+
correlationId: this.correlationId,
|
|
30
|
+
tenantId: this.tenantId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getDuration(): number {
|
|
35
|
+
return Date.now() - this.startTime;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
import { ExecutorRegistry } from './registries/executor.registry.js';
|
|
3
|
+
import { ResourceLimits } from './config.service.js';
|
|
4
|
+
import { GatewayService } from '../gateway/gateway.service.js';
|
|
5
|
+
import { SecurityService } from './security.service.js';
|
|
6
|
+
import { SDKGenerator, toToolBinding } from '../sdk/index.js';
|
|
7
|
+
import { ExecutionContext } from './execution.context.js';
|
|
8
|
+
import { ConduitError } from './types.js';
|
|
9
|
+
import { ExecutionResult } from './interfaces/executor.interface.js';
|
|
10
|
+
|
|
11
|
+
export { ExecutionResult };
|
|
12
|
+
|
|
13
|
+
export class ExecutionService {
|
|
14
|
+
private logger: Logger;
|
|
15
|
+
private executorRegistry: ExecutorRegistry;
|
|
16
|
+
private sdkGenerator = new SDKGenerator();
|
|
17
|
+
private defaultLimits: ResourceLimits;
|
|
18
|
+
private gatewayService: GatewayService;
|
|
19
|
+
private securityService: SecurityService;
|
|
20
|
+
private _ipcAddress: string = '';
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
logger: Logger,
|
|
24
|
+
defaultLimits: ResourceLimits,
|
|
25
|
+
gatewayService: GatewayService,
|
|
26
|
+
securityService: SecurityService,
|
|
27
|
+
executorRegistry: ExecutorRegistry
|
|
28
|
+
) {
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.defaultLimits = defaultLimits;
|
|
31
|
+
this.gatewayService = gatewayService;
|
|
32
|
+
this.securityService = securityService;
|
|
33
|
+
this.executorRegistry = executorRegistry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set ipcAddress(addr: string) {
|
|
37
|
+
this._ipcAddress = addr;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async executeTypeScript(
|
|
41
|
+
code: string,
|
|
42
|
+
limits: ResourceLimits,
|
|
43
|
+
context: ExecutionContext,
|
|
44
|
+
allowedTools?: string[]
|
|
45
|
+
): Promise<ExecutionResult> {
|
|
46
|
+
const effectiveLimits = { ...this.defaultLimits, ...limits };
|
|
47
|
+
|
|
48
|
+
// 1. Validation
|
|
49
|
+
const securityResult = this.securityService.validateCode(code);
|
|
50
|
+
if (!securityResult.valid) {
|
|
51
|
+
return this.createErrorResult(ConduitError.Forbidden, securityResult.message || 'Access denied');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Routing Logic (Isolate vs Deno)
|
|
55
|
+
// Strip simple comments to avoid false positives
|
|
56
|
+
const cleanCode = code.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '$1');
|
|
57
|
+
const hasImports = /^\s*import\s/m.test(cleanCode) ||
|
|
58
|
+
/^\s*export\s/m.test(cleanCode) ||
|
|
59
|
+
/\bDeno\./.test(cleanCode) ||
|
|
60
|
+
/\bDeno\b/.test(cleanCode);
|
|
61
|
+
|
|
62
|
+
// Try to use IsolateExecutor if available and code is simple
|
|
63
|
+
if (!hasImports && this.executorRegistry.has('isolate')) {
|
|
64
|
+
return await this.executeIsolate(code, effectiveLimits, context, allowedTools);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fix Sev2: Ensure IPC address is set before execution if Deno is needed
|
|
68
|
+
if (!this._ipcAddress) {
|
|
69
|
+
return this.createErrorResult(ConduitError.InternalError, 'IPC address not initialized');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Fallback to Deno
|
|
73
|
+
if (!this.executorRegistry.has('deno')) {
|
|
74
|
+
return this.createErrorResult(ConduitError.InternalError, 'Deno execution not available');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const executor = this.executorRegistry.get('deno')!;
|
|
78
|
+
|
|
79
|
+
// 4. SDK Generation
|
|
80
|
+
const bindings = await this.getToolBindings(context);
|
|
81
|
+
const sdkCode = this.sdkGenerator.generateTypeScript(bindings, allowedTools);
|
|
82
|
+
|
|
83
|
+
// 5. Session & Execution
|
|
84
|
+
const sessionToken = this.securityService.createSession(allowedTools);
|
|
85
|
+
try {
|
|
86
|
+
return await executor.execute(code, effectiveLimits, context, {
|
|
87
|
+
ipcAddress: this._ipcAddress,
|
|
88
|
+
ipcToken: sessionToken,
|
|
89
|
+
sdkCode
|
|
90
|
+
});
|
|
91
|
+
} finally {
|
|
92
|
+
this.securityService.invalidateSession(sessionToken);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async executePython(
|
|
97
|
+
code: string,
|
|
98
|
+
limits: ResourceLimits,
|
|
99
|
+
context: ExecutionContext,
|
|
100
|
+
allowedTools?: string[]
|
|
101
|
+
): Promise<ExecutionResult> {
|
|
102
|
+
const effectiveLimits = { ...this.defaultLimits, ...limits };
|
|
103
|
+
|
|
104
|
+
if (!this.executorRegistry.has('python')) {
|
|
105
|
+
return this.createErrorResult(ConduitError.InternalError, 'Python execution not available');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fix Sev2: Ensure IPC address is set before execution for Python
|
|
109
|
+
if (!this._ipcAddress) {
|
|
110
|
+
return this.createErrorResult(ConduitError.InternalError, 'IPC address not initialized');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const executor = this.executorRegistry.get('python')!;
|
|
114
|
+
|
|
115
|
+
const securityResult = this.securityService.validateCode(code);
|
|
116
|
+
if (!securityResult.valid) {
|
|
117
|
+
return this.createErrorResult(ConduitError.Forbidden, securityResult.message || 'Access denied');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bindings = await this.getToolBindings(context);
|
|
121
|
+
const sdkCode = this.sdkGenerator.generatePython(bindings, allowedTools);
|
|
122
|
+
|
|
123
|
+
const sessionToken = this.securityService.createSession(allowedTools);
|
|
124
|
+
try {
|
|
125
|
+
return await executor.execute(code, effectiveLimits, context, {
|
|
126
|
+
ipcAddress: this._ipcAddress,
|
|
127
|
+
ipcToken: sessionToken,
|
|
128
|
+
sdkCode
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
this.securityService.invalidateSession(sessionToken);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async getToolBindings(context: ExecutionContext) {
|
|
136
|
+
// Phase 1: Lazy loading - fetch stubs instead of full schemas
|
|
137
|
+
const packages = await this.gatewayService.listToolPackages();
|
|
138
|
+
const allBindings = [];
|
|
139
|
+
|
|
140
|
+
for (const pkg of packages) {
|
|
141
|
+
try {
|
|
142
|
+
// Determine if we need to fetch tools for this package
|
|
143
|
+
// Optimization: if allowedTools is strict, we could filter packages here
|
|
144
|
+
|
|
145
|
+
const stubs = await this.gatewayService.listToolStubs(pkg.id, context);
|
|
146
|
+
allBindings.push(...stubs.map(s => toToolBinding(s.id, undefined, s.description)));
|
|
147
|
+
} catch (err: any) {
|
|
148
|
+
this.logger.warn({ packageId: pkg.id, err: err.message }, 'Failed to list stubs for package');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return allBindings;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async executeIsolate(
|
|
155
|
+
code: string,
|
|
156
|
+
limits: ResourceLimits,
|
|
157
|
+
context: ExecutionContext,
|
|
158
|
+
allowedTools?: string[]
|
|
159
|
+
): Promise<ExecutionResult> {
|
|
160
|
+
if (!this.executorRegistry.has('isolate')) {
|
|
161
|
+
return this.createErrorResult(ConduitError.InternalError, 'IsolateExecutor not available');
|
|
162
|
+
}
|
|
163
|
+
const executor = this.executorRegistry.get('isolate')!;
|
|
164
|
+
|
|
165
|
+
const effectiveLimits = { ...this.defaultLimits, ...limits };
|
|
166
|
+
const securityResult = this.securityService.validateCode(code);
|
|
167
|
+
if (!securityResult.valid) {
|
|
168
|
+
return this.createErrorResult(ConduitError.Forbidden, securityResult.message || 'Access denied');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const bindings = await this.getToolBindings(context);
|
|
172
|
+
const sdkCode = this.sdkGenerator.generateIsolateSDK(bindings, allowedTools);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
return await executor.execute(code, effectiveLimits, context, { sdkCode });
|
|
176
|
+
} catch (err: any) {
|
|
177
|
+
return this.createErrorResult(ConduitError.InternalError, err.message);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private createErrorResult(code: number, message: string): ExecutionResult {
|
|
182
|
+
return {
|
|
183
|
+
stdout: '',
|
|
184
|
+
stderr: '',
|
|
185
|
+
exitCode: null,
|
|
186
|
+
error: { code, message }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async shutdown(): Promise<void> {
|
|
191
|
+
await this.executorRegistry.shutdownAll();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async warmup(): Promise<void> {
|
|
195
|
+
const pythonExecutor = this.executorRegistry.get('python');
|
|
196
|
+
if (pythonExecutor && 'warmup' in pythonExecutor) {
|
|
197
|
+
// Cast to any because warmup is not in general Executor interface yet
|
|
198
|
+
await (pythonExecutor as any).warmup(this.defaultLimits);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async healthCheck(): Promise<any> {
|
|
203
|
+
const pythonExecutor = this.executorRegistry.get('python');
|
|
204
|
+
if (pythonExecutor && 'healthCheck' in pythonExecutor) {
|
|
205
|
+
return (pythonExecutor as any).healthCheck();
|
|
206
|
+
}
|
|
207
|
+
return { status: 'ok' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ResourceLimits } from '../config.service.js';
|
|
2
|
+
import { UpstreamInfo } from '../../gateway/upstream.client.js';
|
|
3
|
+
|
|
4
|
+
export interface AppConfig {
|
|
5
|
+
port: number;
|
|
6
|
+
nodeEnv: 'development' | 'production' | 'test';
|
|
7
|
+
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
8
|
+
resourceLimits: ResourceLimits;
|
|
9
|
+
secretRedactionPatterns: string[];
|
|
10
|
+
ipcBearerToken: string;
|
|
11
|
+
maxConcurrent: number;
|
|
12
|
+
denoMaxPoolSize: number;
|
|
13
|
+
pyodideMaxPoolSize: number;
|
|
14
|
+
metricsUrl: string;
|
|
15
|
+
opsPort?: number;
|
|
16
|
+
upstreams?: UpstreamInfo[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ResourceLimits } from '../config.service.js';
|
|
2
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
3
|
+
|
|
4
|
+
export interface ExecutionResult {
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
exitCode: number | null;
|
|
8
|
+
error?: {
|
|
9
|
+
code: number;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExecutorConfig {
|
|
15
|
+
ipcAddress?: string;
|
|
16
|
+
ipcToken?: string;
|
|
17
|
+
sdkCode?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Executor {
|
|
21
|
+
execute(
|
|
22
|
+
code: string,
|
|
23
|
+
limits: ResourceLimits,
|
|
24
|
+
context: ExecutionContext,
|
|
25
|
+
config?: ExecutorConfig
|
|
26
|
+
): Promise<ExecutionResult>;
|
|
27
|
+
|
|
28
|
+
shutdown?(): Promise<void>;
|
|
29
|
+
healthCheck?(): Promise<{ status: string; workers?: number; detail?: string }>;
|
|
30
|
+
warmup?(limits?: ResourceLimits): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ExecutionContext } from '../execution.context.js';
|
|
2
|
+
import { JSONRPCRequest, JSONRPCResponse } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export type NextFunction = () => Promise<JSONRPCResponse>;
|
|
5
|
+
|
|
6
|
+
export interface Middleware {
|
|
7
|
+
handle(
|
|
8
|
+
request: JSONRPCRequest,
|
|
9
|
+
context: ExecutionContext,
|
|
10
|
+
next: NextFunction
|
|
11
|
+
): Promise<JSONRPCResponse>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { ConfigService } from './config.service.js';
|
|
4
|
+
|
|
5
|
+
export const loggerStorage = new AsyncLocalStorage<{ correlationId: string }>();
|
|
6
|
+
|
|
7
|
+
export function createLogger(configService: ConfigService) {
|
|
8
|
+
const logLevel = configService.get('logLevel');
|
|
9
|
+
const redactionPatterns = configService.get('secretRedactionPatterns');
|
|
10
|
+
const secretPatterns = redactionPatterns.map(p => new RegExp(p, 'g'));
|
|
11
|
+
|
|
12
|
+
const redactString = (str: string) => {
|
|
13
|
+
let result = str;
|
|
14
|
+
for (const pattern of secretPatterns) {
|
|
15
|
+
result = result.replace(pattern, '[REDACTED]');
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return pino({
|
|
21
|
+
level: logLevel,
|
|
22
|
+
hooks: {
|
|
23
|
+
logMethod(inputArgs, method) {
|
|
24
|
+
const redactedArgs = inputArgs.map(arg => {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof arg === 'string') {
|
|
27
|
+
return redactString(arg);
|
|
28
|
+
}
|
|
29
|
+
if (typeof arg === 'object' && arg !== null) {
|
|
30
|
+
// Shallow clone and redact keys if they are strings
|
|
31
|
+
const clone = { ...arg } as any;
|
|
32
|
+
for (const key in clone) {
|
|
33
|
+
if (typeof clone[key] === 'string') {
|
|
34
|
+
clone[key] = redactString(clone[key]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return clone;
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return '[REDACTION_ERROR]';
|
|
41
|
+
}
|
|
42
|
+
return arg;
|
|
43
|
+
});
|
|
44
|
+
return method.apply(this, redactedArgs as any);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
redact: {
|
|
48
|
+
paths: ['toolParams.*', 'headers.Authorization', 'headers.authorization', 'params.token'],
|
|
49
|
+
censor: '[REDACTED]',
|
|
50
|
+
},
|
|
51
|
+
mixin() {
|
|
52
|
+
const store = loggerStorage.getStore();
|
|
53
|
+
return {
|
|
54
|
+
correlationId: store?.correlationId,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
transport: configService.get('nodeEnv') === 'development' ? {
|
|
58
|
+
target: 'pino-pretty',
|
|
59
|
+
options: {
|
|
60
|
+
colorize: true,
|
|
61
|
+
}
|
|
62
|
+
} : undefined,
|
|
63
|
+
});
|
|
64
|
+
}
|