reqon-dsl 0.3.0 → 0.4.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/README.md +23 -3
- package/dist/ast/nodes.d.ts +8 -0
- package/dist/auth/circuit-breaker.d.ts +11 -0
- package/dist/auth/circuit-breaker.js +83 -12
- package/dist/auth/credentials.d.ts +6 -1
- package/dist/auth/credentials.js +12 -4
- package/dist/auth/oauth2-provider.js +13 -3
- package/dist/auth/rate-limiter.d.ts +8 -1
- package/dist/auth/rate-limiter.js +30 -10
- package/dist/auth/token-store.js +8 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.js +65 -6
- package/dist/config/constants.d.ts +15 -4
- package/dist/config/constants.js +15 -4
- package/dist/control/server.d.ts +17 -0
- package/dist/control/server.js +82 -5
- package/dist/control/types.d.ts +6 -0
- package/dist/debug/cli-debugger.js +8 -3
- package/dist/execution/store.js +2 -2
- package/dist/execution-log/events.d.ts +125 -0
- package/dist/execution-log/events.js +17 -0
- package/dist/execution-log/fold.d.ts +38 -0
- package/dist/execution-log/fold.js +54 -0
- package/dist/execution-log/index.d.ts +18 -0
- package/dist/execution-log/index.js +6 -0
- package/dist/execution-log/postgres-store.d.ts +36 -0
- package/dist/execution-log/postgres-store.js +108 -0
- package/dist/execution-log/resume.d.ts +11 -0
- package/dist/execution-log/resume.js +5 -0
- package/dist/execution-log/sqlite-store.d.ts +16 -0
- package/dist/execution-log/sqlite-store.js +101 -0
- package/dist/execution-log/store.d.ts +72 -0
- package/dist/execution-log/store.js +182 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/interpreter/context.d.ts +15 -0
- package/dist/interpreter/context.js +3 -0
- package/dist/interpreter/evaluator.js +38 -8
- package/dist/interpreter/executor.d.ts +63 -1
- package/dist/interpreter/executor.js +406 -30
- package/dist/interpreter/fetch-handler.d.ts +39 -1
- package/dist/interpreter/fetch-handler.js +84 -15
- package/dist/interpreter/http.d.ts +31 -2
- package/dist/interpreter/http.js +187 -26
- package/dist/interpreter/index.d.ts +3 -3
- package/dist/interpreter/index.js +3 -3
- package/dist/interpreter/pagination.d.ts +1 -1
- package/dist/interpreter/pagination.js +7 -1
- package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
- package/dist/interpreter/step-handlers/for-handler.js +18 -3
- package/dist/interpreter/step-handlers/match-handler.js +5 -2
- package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
- package/dist/interpreter/step-handlers/store-handler.js +25 -16
- package/dist/interpreter/step-handlers/validate-handler.js +4 -1
- package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
- package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
- package/dist/interpreter/store-manager.d.ts +1 -1
- package/dist/interpreter/store-manager.js +5 -1
- package/dist/loader/index.js +5 -8
- package/dist/mcp/sandbox.d.ts +41 -0
- package/dist/mcp/sandbox.js +76 -0
- package/dist/mcp/server.js +62 -9
- package/dist/oas/loader.d.ts +13 -1
- package/dist/oas/loader.js +25 -3
- package/dist/oas/mock-generator.js +13 -4
- package/dist/oas/validator.js +45 -5
- package/dist/observability/events.d.ts +6 -2
- package/dist/observability/events.js +0 -5
- package/dist/observability/logger.js +17 -10
- package/dist/observability/otel.d.ts +8 -0
- package/dist/observability/otel.js +45 -10
- package/dist/parser/action-parser.js +2 -2
- package/dist/parser/base.d.ts +7 -0
- package/dist/parser/base.js +11 -0
- package/dist/parser/expressions.d.ts +1 -0
- package/dist/parser/expressions.js +17 -4
- package/dist/parser/fetch-parser.js +13 -2
- package/dist/pause/index.d.ts +1 -0
- package/dist/pause/index.js +1 -0
- package/dist/pause/log-store.d.ts +33 -0
- package/dist/pause/log-store.js +98 -0
- package/dist/pause/manager.d.ts +12 -0
- package/dist/pause/manager.js +77 -28
- package/dist/pause/store.js +5 -3
- package/dist/scheduler/cron-parser.d.ts +10 -3
- package/dist/scheduler/cron-parser.js +227 -48
- package/dist/scheduler/scheduler.js +56 -22
- package/dist/stores/factory.d.ts +6 -0
- package/dist/stores/factory.js +11 -1
- package/dist/stores/file.js +9 -17
- package/dist/stores/memory.js +3 -12
- package/dist/stores/postgrest.d.ts +28 -0
- package/dist/stores/postgrest.js +84 -37
- package/dist/sync/index.d.ts +3 -2
- package/dist/sync/index.js +2 -1
- package/dist/sync/log-store.d.ts +30 -0
- package/dist/sync/log-store.js +45 -0
- package/dist/sync/store.js +1 -1
- package/dist/trace/index.d.ts +2 -0
- package/dist/trace/index.js +1 -0
- package/dist/trace/log-view.d.ts +57 -0
- package/dist/trace/log-view.js +76 -0
- package/dist/trace/recorder.d.ts +5 -1
- package/dist/trace/recorder.js +19 -6
- package/dist/trace/store.d.ts +6 -0
- package/dist/trace/store.js +47 -22
- package/dist/utils/deep-merge.d.ts +10 -0
- package/dist/utils/deep-merge.js +23 -0
- package/dist/utils/file.d.ts +13 -4
- package/dist/utils/file.js +70 -12
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/long-timeout.d.ts +19 -0
- package/dist/utils/long-timeout.js +33 -0
- package/dist/utils/path.d.ts +22 -1
- package/dist/utils/path.js +46 -1
- package/dist/utils/redact.d.ts +22 -0
- package/dist/utils/redact.js +42 -0
- package/dist/webhook/server.d.ts +9 -0
- package/dist/webhook/server.js +115 -30
- package/dist/webhook/types.d.ts +9 -1
- package/package.json +22 -4
|
@@ -167,7 +167,7 @@ export class CursorPaginationStrategy {
|
|
|
167
167
|
}
|
|
168
168
|
return {};
|
|
169
169
|
}
|
|
170
|
-
extractResults(response,
|
|
170
|
+
extractResults(response, _ctx) {
|
|
171
171
|
if (!response || typeof response !== 'object') {
|
|
172
172
|
return { items: [], hasMore: false };
|
|
173
173
|
}
|
|
@@ -206,6 +206,12 @@ export class CursorPaginationStrategy {
|
|
|
206
206
|
* Create the appropriate pagination strategy based on config
|
|
207
207
|
*/
|
|
208
208
|
export function createPaginationStrategy(config) {
|
|
209
|
+
// A non-positive page size makes every page request the same offset and makes
|
|
210
|
+
// the `items.length >= pageSize` termination test always true, so pagination
|
|
211
|
+
// re-fetches page one until it hits the page cap. Reject it up front.
|
|
212
|
+
if (!Number.isFinite(config.pageSize) || config.pageSize < 1) {
|
|
213
|
+
throw new Error(`Pagination page size must be a positive integer, got ${config.pageSize}`);
|
|
214
|
+
}
|
|
209
215
|
switch (config.type) {
|
|
210
216
|
case 'offset':
|
|
211
217
|
return new OffsetPaginationStrategy(config);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ForStep, ActionStep } from '../../ast/nodes.js';
|
|
2
2
|
import type { StepHandler, StepHandlerDeps } from './types.js';
|
|
3
3
|
import type { ExecutionContext } from '../context.js';
|
|
4
|
+
import { QueueSignal } from '../signals.js';
|
|
4
5
|
import type { DebugController, DebugSnapshot } from '../../debug/index.js';
|
|
5
6
|
export interface ForHandlerDeps extends StepHandlerDeps {
|
|
6
7
|
executeStep: (step: ActionStep, actionName: string, ctx: ExecutionContext) => Promise<void>;
|
|
@@ -17,6 +18,8 @@ export interface ForHandlerDeps extends StepHandlerDeps {
|
|
|
17
18
|
}) => void;
|
|
18
19
|
/** Optional callback to check for pause requests (called every N iterations) */
|
|
19
20
|
checkPause?: () => Promise<void>;
|
|
21
|
+
/** Handle a `queue` directive raised within a loop item. */
|
|
22
|
+
handleQueue?: (signal: QueueSignal) => Promise<void>;
|
|
20
23
|
}
|
|
21
24
|
/**
|
|
22
25
|
* Handles for...in...where iteration steps
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { evaluate } from '../evaluator.js';
|
|
2
2
|
import { childContext, setVariable, getVariable } from '../context.js';
|
|
3
3
|
import { StepError } from '../../errors/index.js';
|
|
4
|
+
import { SkipSignal, QueueSignal } from '../signals.js';
|
|
4
5
|
/** Heartbeat interval for loop iterations */
|
|
5
6
|
const LOOP_HEARTBEAT_INTERVAL = 10;
|
|
6
7
|
/**
|
|
@@ -110,9 +111,23 @@ export class ForHandler {
|
|
|
110
111
|
async executeForItem(step, item) {
|
|
111
112
|
const childCtx = childContext(this.deps.ctx);
|
|
112
113
|
setVariable(childCtx, step.variable, item);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
try {
|
|
115
|
+
// Execute each inner step with child context
|
|
116
|
+
for (const innerStep of step.steps) {
|
|
117
|
+
await this.deps.executeStep(innerStep, this.deps.actionName, childCtx);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// `skip` skips the rest of this item's steps; the loop continues.
|
|
122
|
+
if (error instanceof SkipSignal) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// `queue` stashes the item and moves on to the next iteration.
|
|
126
|
+
if (error instanceof QueueSignal) {
|
|
127
|
+
await this.deps.handleQueue?.(error);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
}
|
|
@@ -48,7 +48,9 @@ export class MatchHandler {
|
|
|
48
48
|
// Execute steps
|
|
49
49
|
if (matchedArm.steps) {
|
|
50
50
|
// Debug pause point - before match arm body (step-into mode)
|
|
51
|
-
if (this.deps.debugController &&
|
|
51
|
+
if (this.deps.debugController &&
|
|
52
|
+
this.deps.captureDebugSnapshot &&
|
|
53
|
+
this.deps.handleDebugCommand) {
|
|
52
54
|
const location = {
|
|
53
55
|
action: this.deps.actionName,
|
|
54
56
|
stepIndex: -1, // Use -1 for match arms
|
|
@@ -79,10 +81,11 @@ export class MatchHandler {
|
|
|
79
81
|
throw new JumpSignal(flow.action, flow.then);
|
|
80
82
|
case 'queue':
|
|
81
83
|
throw new QueueSignal(value, flow.target);
|
|
82
|
-
default:
|
|
84
|
+
default: {
|
|
83
85
|
// This should never happen if TypeScript is working correctly
|
|
84
86
|
const _exhaustive = flow;
|
|
85
87
|
throw new Error(`Unknown flow directive: ${_exhaustive.type}`);
|
|
88
|
+
}
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -9,7 +9,11 @@ export declare class StoreHandler implements StepHandler<StoreStep> {
|
|
|
9
9
|
constructor(deps: StepHandlerDeps);
|
|
10
10
|
/**
|
|
11
11
|
* Compute the storage key for a record based on step options.
|
|
12
|
-
*
|
|
12
|
+
*
|
|
13
|
+
* Uses the key expression if provided, otherwise falls back to `record.id`.
|
|
14
|
+
* A missing or null/undefined key is an error: inventing a random key breaks
|
|
15
|
+
* dedup (re-runs duplicate everything), and stringifying undefined collapses
|
|
16
|
+
* every such record onto the literal key "undefined".
|
|
13
17
|
*/
|
|
14
18
|
private getRecordKey;
|
|
15
19
|
/**
|
|
@@ -19,5 +23,7 @@ export declare class StoreHandler implements StepHandler<StoreStep> {
|
|
|
19
23
|
execute(step: StoreStep): Promise<void>;
|
|
20
24
|
private storeMany;
|
|
21
25
|
private storeOne;
|
|
26
|
+
/** True when the record should be merged into an existing one (deep upsert). */
|
|
27
|
+
private shouldMerge;
|
|
22
28
|
private storeRecord;
|
|
23
29
|
}
|
|
@@ -11,13 +11,20 @@ export class StoreHandler {
|
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Compute the storage key for a record based on step options.
|
|
14
|
-
*
|
|
14
|
+
*
|
|
15
|
+
* Uses the key expression if provided, otherwise falls back to `record.id`.
|
|
16
|
+
* A missing or null/undefined key is an error: inventing a random key breaks
|
|
17
|
+
* dedup (re-runs duplicate everything), and stringifying undefined collapses
|
|
18
|
+
* every such record onto the literal key "undefined".
|
|
15
19
|
*/
|
|
16
20
|
getRecordKey(step, record) {
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
const raw = step.options.key ? evaluate(step.options.key, this.deps.ctx, record) : record.id;
|
|
22
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
23
|
+
const which = step.options.key ? 'key expression' : "record 'id'";
|
|
24
|
+
throw new RuntimeError(`Cannot store to '${step.target}': ${which} is missing or empty. ` +
|
|
25
|
+
`Provide a 'key:' option that resolves to a stable, non-empty value.`, { line: 1, column: 1 }, undefined, { stepType: 'store' });
|
|
19
26
|
}
|
|
20
|
-
return String(
|
|
27
|
+
return String(raw);
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
30
|
* Emit a data.store event with operation metadata.
|
|
@@ -34,7 +41,9 @@ export class StoreHandler {
|
|
|
34
41
|
async execute(step) {
|
|
35
42
|
const store = this.deps.ctx.stores.get(step.target);
|
|
36
43
|
if (!store) {
|
|
37
|
-
throw new RuntimeError(`Store not found: ${step.target}`, { line: 1, column: 1 }, undefined, {
|
|
44
|
+
throw new RuntimeError(`Store not found: ${step.target}`, { line: 1, column: 1 }, undefined, {
|
|
45
|
+
stepType: 'store',
|
|
46
|
+
});
|
|
38
47
|
}
|
|
39
48
|
const source = evaluate(step.source, this.deps.ctx);
|
|
40
49
|
if (Array.isArray(source)) {
|
|
@@ -45,18 +54,16 @@ export class StoreHandler {
|
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
async storeMany(step, store, items) {
|
|
48
|
-
const
|
|
57
|
+
const merge = this.shouldMerge(step);
|
|
58
|
+
const operation = merge ? 'upsert' : 'set';
|
|
49
59
|
// Check if we can use bulk operations
|
|
50
|
-
const canBulkSet = store.bulkSet && !
|
|
51
|
-
const canBulkUpsert = store.bulkUpsert &&
|
|
60
|
+
const canBulkSet = store.bulkSet && !merge;
|
|
61
|
+
const canBulkUpsert = store.bulkUpsert && merge;
|
|
52
62
|
if (canBulkSet || canBulkUpsert) {
|
|
53
63
|
const records = [];
|
|
54
64
|
for (const item of items) {
|
|
55
65
|
const record = item;
|
|
56
66
|
const key = this.getRecordKey(step, record);
|
|
57
|
-
if (step.options.partial !== undefined) {
|
|
58
|
-
record._partial = step.options.partial;
|
|
59
|
-
}
|
|
60
67
|
records.push({ key, value: record });
|
|
61
68
|
}
|
|
62
69
|
if (canBulkUpsert) {
|
|
@@ -78,17 +85,19 @@ export class StoreHandler {
|
|
|
78
85
|
}
|
|
79
86
|
async storeOne(step, store, record) {
|
|
80
87
|
const key = this.getRecordKey(step, record);
|
|
81
|
-
const operation = step
|
|
88
|
+
const operation = this.shouldMerge(step) ? 'upsert' : 'set';
|
|
82
89
|
await this.storeRecord(step, store, record);
|
|
83
90
|
this.deps.log(`Stored item to ${step.target}`);
|
|
84
91
|
this.emitStoreEvent(step, operation, 1, key);
|
|
85
92
|
}
|
|
93
|
+
/** True when the record should be merged into an existing one (deep upsert). */
|
|
94
|
+
shouldMerge(step) {
|
|
95
|
+
return step.options.upsert === true || step.options.partial === true;
|
|
96
|
+
}
|
|
86
97
|
async storeRecord(step, store, record) {
|
|
87
98
|
const key = this.getRecordKey(step, record);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
if (step.options.upsert) {
|
|
99
|
+
// Never mutate the caller's record or persist a storage-internal flag.
|
|
100
|
+
if (this.shouldMerge(step)) {
|
|
92
101
|
await store.update(key, record);
|
|
93
102
|
}
|
|
94
103
|
else {
|
|
@@ -16,7 +16,10 @@ export class ValidateHandler {
|
|
|
16
16
|
if (!result) {
|
|
17
17
|
const message = constraint.message ?? `Validation failed: ${JSON.stringify(constraint.condition)}`;
|
|
18
18
|
if (constraint.severity === 'error') {
|
|
19
|
-
throw new ValidationError(message, { line: 1, column: 1 }, undefined, {
|
|
19
|
+
throw new ValidationError(message, { line: 1, column: 1 }, undefined, {
|
|
20
|
+
constraint: JSON.stringify(constraint.condition),
|
|
21
|
+
severity: 'error',
|
|
22
|
+
});
|
|
20
23
|
}
|
|
21
24
|
else {
|
|
22
25
|
this.deps.log(`Warning: ${message}`);
|
|
@@ -44,7 +44,18 @@ export class WebhookHandler {
|
|
|
44
44
|
webhookUrl,
|
|
45
45
|
webhookPath: registration.path,
|
|
46
46
|
};
|
|
47
|
-
// Wait for webhook events
|
|
47
|
+
// Wait for webhook events. The registration must be torn down on every
|
|
48
|
+
// exit path (timeout, filter throw, store failure, retry signal) or it
|
|
49
|
+
// leaks the server-side handle — hence the try/finally below.
|
|
50
|
+
try {
|
|
51
|
+
return await this.waitAndProcess(step, registration, webhookUrl, timeout, ctx, log, emit);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await webhookServer.unregister(registration.id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async waitAndProcess(step, registration, webhookUrl, timeout, ctx, log, emit) {
|
|
58
|
+
const webhookServer = this.deps.webhookServer;
|
|
48
59
|
const result = await webhookServer.waitForEvents(registration.id, timeout);
|
|
49
60
|
if (result.timedOut) {
|
|
50
61
|
log(`Webhook timeout: ${webhookUrl}`);
|
|
@@ -109,8 +120,7 @@ export class WebhookHandler {
|
|
|
109
120
|
timedOut: result.timedOut ?? false,
|
|
110
121
|
storedTo: step.storage?.target,
|
|
111
122
|
});
|
|
112
|
-
//
|
|
113
|
-
await webhookServer.unregister(registration.id);
|
|
123
|
+
// Registration teardown happens in the caller's finally block.
|
|
114
124
|
return {
|
|
115
125
|
registration,
|
|
116
126
|
events,
|
|
@@ -13,7 +13,7 @@ import type { StoreAdapter } from '../stores/types.js';
|
|
|
13
13
|
export interface StoreManagerConfig {
|
|
14
14
|
/** Custom store adapters by name */
|
|
15
15
|
customStores?: Record<string, StoreAdapter>;
|
|
16
|
-
/** Development mode - use file stores instead of sql/nosql (default:
|
|
16
|
+
/** Development mode - use file stores instead of sql/nosql (default: false) */
|
|
17
17
|
developmentMode?: boolean;
|
|
18
18
|
/** Base directory for file stores (default: '.reqon-data') */
|
|
19
19
|
dataDir?: string;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - Resolving store types for development/production modes
|
|
9
9
|
*/
|
|
10
10
|
import { createStore, resolveStoreType } from '../stores/index.js';
|
|
11
|
+
import { EXECUTION_DEFAULTS } from '../config/index.js';
|
|
11
12
|
/**
|
|
12
13
|
* Manages store initialization and provides access to store adapters.
|
|
13
14
|
*/
|
|
@@ -28,12 +29,15 @@ export class StoreManager {
|
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
31
|
// Use store factory to create appropriate adapter
|
|
31
|
-
const developmentMode = this.config.developmentMode ??
|
|
32
|
+
const developmentMode = this.config.developmentMode ?? EXECUTION_DEFAULTS.DEVELOPMENT_MODE;
|
|
32
33
|
const storeType = resolveStoreType(store.storeType, developmentMode);
|
|
33
34
|
const adapter = createStore({
|
|
34
35
|
type: storeType,
|
|
35
36
|
name: store.target,
|
|
36
37
|
baseDir: this.config.dataDir,
|
|
38
|
+
// In dev mode the type is already resolved to 'file'; this only matters
|
|
39
|
+
// if a raw sql/nosql type reaches the factory.
|
|
40
|
+
allowFileFallback: developmentMode,
|
|
37
41
|
});
|
|
38
42
|
ctx.stores.set(store.name, adapter);
|
|
39
43
|
ctx.storeTypes.set(store.name, storeType);
|
package/dist/loader/index.js
CHANGED
|
@@ -59,9 +59,7 @@ async function loadMissionFile(filePath) {
|
|
|
59
59
|
*/
|
|
60
60
|
async function loadMissionFolder(folderPath, options) {
|
|
61
61
|
// Find root file - try extensions in order of preference
|
|
62
|
-
const extensionsToTry = options.extension
|
|
63
|
-
? [options.extension]
|
|
64
|
-
: SUPPORTED_EXTENSIONS;
|
|
62
|
+
const extensionsToTry = options.extension ? [options.extension] : SUPPORTED_EXTENSIONS;
|
|
65
63
|
let rootFilePath = null;
|
|
66
64
|
let ext = null;
|
|
67
65
|
for (const extension of extensionsToTry) {
|
|
@@ -77,9 +75,8 @@ async function loadMissionFolder(folderPath, options) {
|
|
|
77
75
|
}
|
|
78
76
|
}
|
|
79
77
|
if (!rootFilePath || !ext) {
|
|
80
|
-
const tried = extensionsToTry.map(e => `mission${e}`).join(' or ');
|
|
81
|
-
throw new Error(`Mission folder must contain a root file (${tried}). ` +
|
|
82
|
-
`Not found in: ${folderPath}`);
|
|
78
|
+
const tried = extensionsToTry.map((e) => `mission${e}`).join(' or ');
|
|
79
|
+
throw new Error(`Mission folder must contain a root file (${tried}). ` + `Not found in: ${folderPath}`);
|
|
83
80
|
}
|
|
84
81
|
// Load root file
|
|
85
82
|
const rootSource = await readFile(rootFilePath, 'utf-8');
|
|
@@ -87,7 +84,7 @@ async function loadMissionFolder(folderPath, options) {
|
|
|
87
84
|
// Find all other action files in the folder (same extension as root)
|
|
88
85
|
const rootFileName = basename(rootFilePath);
|
|
89
86
|
const files = await readdir(folderPath);
|
|
90
|
-
const actionFiles = files.filter(f => f.endsWith(ext) && f !== rootFileName);
|
|
87
|
+
const actionFiles = files.filter((f) => f.endsWith(ext) && f !== rootFileName);
|
|
91
88
|
const sourceFiles = [rootFilePath];
|
|
92
89
|
const externalActions = [];
|
|
93
90
|
// Parse each action file
|
|
@@ -169,7 +166,7 @@ function validatePipelineActions(program) {
|
|
|
169
166
|
const mission = program.statements.find((s) => s.type === 'MissionDefinition');
|
|
170
167
|
if (!mission)
|
|
171
168
|
return;
|
|
172
|
-
const actionNames = new Set(mission.actions.map(a => a.name));
|
|
169
|
+
const actionNames = new Set(mission.actions.map((a) => a.name));
|
|
173
170
|
for (const stage of mission.pipeline.stages) {
|
|
174
171
|
const stageActions = stage.actions ?? (stage.action ? [stage.action] : []);
|
|
175
172
|
for (const actionName of stageActions) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxing helpers for the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server is driven by an LLM acting on untrusted input, so missions
|
|
5
|
+
* must not be able to reach the network/filesystem or escape the working
|
|
6
|
+
* directory unless the operator explicitly opts in.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resolve an untrusted path against a base working directory and assert the
|
|
10
|
+
* result stays inside it. Blocks `../` and absolute-path escapes.
|
|
11
|
+
*
|
|
12
|
+
* @throws if the resolved path escapes the working directory
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveWithinWorkingDir(workingDir: string, p: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Decide the effective dryRun for an execution. Effects (real network/fs) are
|
|
17
|
+
* opt-in: when not allowed, force dryRun regardless of what the caller asked.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveDryRun(allowEffects: boolean, requestedDryRun?: boolean): boolean;
|
|
20
|
+
/** Default wall-clock budget for an untrusted mission execution. */
|
|
21
|
+
export declare const DEFAULT_EXECUTION_TIMEOUT_MS = 30000;
|
|
22
|
+
/** Thrown when an execution exceeds its allotted time budget. */
|
|
23
|
+
export declare class ExecutionTimeoutError extends Error {
|
|
24
|
+
readonly timeoutMs: number;
|
|
25
|
+
constructor(timeoutMs: number);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Race a promise against a timeout. An untrusted (LLM-supplied) mission could
|
|
29
|
+
* otherwise loop or hang forever (e.g. a long pause/wait) and wedge the server.
|
|
30
|
+
* On timeout the returned promise rejects with an {@link ExecutionTimeoutError};
|
|
31
|
+
* the underlying work may keep running, but the caller is no longer blocked.
|
|
32
|
+
*
|
|
33
|
+
* A non-positive or non-finite timeout disables the guard.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T>;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a store of the given type may be registered under the current effects
|
|
38
|
+
* policy. File-backed stores write to disk, which is an effect, so they require
|
|
39
|
+
* effects to be enabled. In-memory stores are always allowed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isStoreTypeAllowed(storeType: string | undefined, allowEffects: boolean): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxing helpers for the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server is driven by an LLM acting on untrusted input, so missions
|
|
5
|
+
* must not be able to reach the network/filesystem or escape the working
|
|
6
|
+
* directory unless the operator explicitly opts in.
|
|
7
|
+
*/
|
|
8
|
+
import { resolve, sep } from 'node:path';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve an untrusted path against a base working directory and assert the
|
|
11
|
+
* result stays inside it. Blocks `../` and absolute-path escapes.
|
|
12
|
+
*
|
|
13
|
+
* @throws if the resolved path escapes the working directory
|
|
14
|
+
*/
|
|
15
|
+
export function resolveWithinWorkingDir(workingDir, p) {
|
|
16
|
+
const base = resolve(workingDir);
|
|
17
|
+
const resolved = resolve(base, p);
|
|
18
|
+
if (resolved !== base && !resolved.startsWith(base + sep)) {
|
|
19
|
+
throw new Error(`Path "${p}" escapes the working directory`);
|
|
20
|
+
}
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Decide the effective dryRun for an execution. Effects (real network/fs) are
|
|
25
|
+
* opt-in: when not allowed, force dryRun regardless of what the caller asked.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveDryRun(allowEffects, requestedDryRun) {
|
|
28
|
+
if (!allowEffects)
|
|
29
|
+
return true;
|
|
30
|
+
return requestedDryRun ?? false;
|
|
31
|
+
}
|
|
32
|
+
/** Default wall-clock budget for an untrusted mission execution. */
|
|
33
|
+
export const DEFAULT_EXECUTION_TIMEOUT_MS = 30_000;
|
|
34
|
+
/** Thrown when an execution exceeds its allotted time budget. */
|
|
35
|
+
export class ExecutionTimeoutError extends Error {
|
|
36
|
+
timeoutMs;
|
|
37
|
+
constructor(timeoutMs) {
|
|
38
|
+
super(`Execution exceeded the ${timeoutMs}ms timeout`);
|
|
39
|
+
this.timeoutMs = timeoutMs;
|
|
40
|
+
this.name = 'ExecutionTimeoutError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Race a promise against a timeout. An untrusted (LLM-supplied) mission could
|
|
45
|
+
* otherwise loop or hang forever (e.g. a long pause/wait) and wedge the server.
|
|
46
|
+
* On timeout the returned promise rejects with an {@link ExecutionTimeoutError};
|
|
47
|
+
* the underlying work may keep running, but the caller is no longer blocked.
|
|
48
|
+
*
|
|
49
|
+
* A non-positive or non-finite timeout disables the guard.
|
|
50
|
+
*/
|
|
51
|
+
export function withTimeout(promise, timeoutMs) {
|
|
52
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
53
|
+
return promise;
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(() => reject(new ExecutionTimeoutError(timeoutMs)), timeoutMs);
|
|
56
|
+
// Don't let the timer keep the event loop alive on its own.
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
promise.then((value) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve(value);
|
|
61
|
+
}, (error) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
reject(error);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether a store of the given type may be registered under the current effects
|
|
69
|
+
* policy. File-backed stores write to disk, which is an effect, so they require
|
|
70
|
+
* effects to be enabled. In-memory stores are always allowed.
|
|
71
|
+
*/
|
|
72
|
+
export function isStoreTypeAllowed(storeType, allowEffects) {
|
|
73
|
+
if (storeType === 'file')
|
|
74
|
+
return allowEffects;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -17,14 +17,41 @@
|
|
|
17
17
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
18
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
19
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { resolveWithinWorkingDir as confinePath, resolveDryRun, withTimeout, isStoreTypeAllowed, DEFAULT_EXECUTION_TIMEOUT_MS, } from './sandbox.js';
|
|
20
22
|
import { parse, execute, fromPath } from '../index.js';
|
|
21
|
-
import { createStore
|
|
23
|
+
import { createStore } from '../stores/index.js';
|
|
22
24
|
// Global store registry for cross-execution access
|
|
23
25
|
const storeRegistry = new Map();
|
|
24
|
-
|
|
26
|
+
const serverConfig = {
|
|
25
27
|
workingDirectory: process.cwd(),
|
|
26
28
|
verbose: false,
|
|
29
|
+
allowEffects: false,
|
|
30
|
+
executionTimeoutMs: DEFAULT_EXECUTION_TIMEOUT_MS,
|
|
27
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an untrusted path against the configured working directory and
|
|
34
|
+
* assert it stays inside it. Blocks `../` / absolute-path escapes.
|
|
35
|
+
*/
|
|
36
|
+
function resolveWithinWorkingDir(p) {
|
|
37
|
+
return confinePath(serverConfig.workingDirectory ?? process.cwd(), p);
|
|
38
|
+
}
|
|
39
|
+
// Argument schemas (validated against each tool's inputSchema via zod).
|
|
40
|
+
const executeArgsSchema = z.object({
|
|
41
|
+
source: z.string(),
|
|
42
|
+
verbose: z.boolean().optional(),
|
|
43
|
+
dryRun: z.boolean().optional(),
|
|
44
|
+
});
|
|
45
|
+
const executeFileArgsSchema = z.object({
|
|
46
|
+
path: z.string(),
|
|
47
|
+
verbose: z.boolean().optional(),
|
|
48
|
+
dryRun: z.boolean().optional(),
|
|
49
|
+
});
|
|
50
|
+
const registerStoreArgsSchema = z.object({
|
|
51
|
+
name: z.string(),
|
|
52
|
+
type: z.enum(['memory', 'file']).optional(),
|
|
53
|
+
path: z.string().optional(),
|
|
54
|
+
});
|
|
28
55
|
/**
|
|
29
56
|
* Format execution result for MCP response
|
|
30
57
|
*/
|
|
@@ -54,9 +81,12 @@ function formatExecutionResult(result) {
|
|
|
54
81
|
* Create executor config
|
|
55
82
|
*/
|
|
56
83
|
function createExecutorConfig(options) {
|
|
84
|
+
// Effects are opt-in at the server level. When disabled, force dryRun so an
|
|
85
|
+
// untrusted mission cannot reach the network or write to disk, regardless of
|
|
86
|
+
// what the caller requests.
|
|
57
87
|
return {
|
|
58
88
|
verbose: options?.verbose ?? serverConfig.verbose,
|
|
59
|
-
dryRun:
|
|
89
|
+
dryRun: resolveDryRun(serverConfig.allowEffects ?? false, options?.dryRun),
|
|
60
90
|
};
|
|
61
91
|
}
|
|
62
92
|
// Define available tools
|
|
@@ -208,9 +238,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
208
238
|
try {
|
|
209
239
|
switch (name) {
|
|
210
240
|
case 'reqon.execute': {
|
|
211
|
-
const { source, verbose, dryRun } = args;
|
|
241
|
+
const { source, verbose, dryRun } = executeArgsSchema.parse(args);
|
|
212
242
|
const config = createExecutorConfig({ verbose, dryRun });
|
|
213
|
-
const result = await execute(source, config);
|
|
243
|
+
const result = await withTimeout(execute(source, config), serverConfig.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
214
244
|
return {
|
|
215
245
|
content: [
|
|
216
246
|
{
|
|
@@ -221,9 +251,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
221
251
|
};
|
|
222
252
|
}
|
|
223
253
|
case 'reqon.execute_file': {
|
|
224
|
-
const { path, verbose, dryRun } = args;
|
|
254
|
+
const { path, verbose, dryRun } = executeFileArgsSchema.parse(args);
|
|
255
|
+
const safePath = resolveWithinWorkingDir(path);
|
|
225
256
|
const config = createExecutorConfig({ verbose, dryRun });
|
|
226
|
-
const result = await fromPath(
|
|
257
|
+
const result = await withTimeout(fromPath(safePath, config), serverConfig.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
227
258
|
return {
|
|
228
259
|
content: [
|
|
229
260
|
{
|
|
@@ -308,7 +339,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
308
339
|
};
|
|
309
340
|
}
|
|
310
341
|
case 'reqon.register_store': {
|
|
311
|
-
const { name, type = 'memory', path } = args;
|
|
342
|
+
const { name, type = 'memory', path } = registerStoreArgsSchema.parse(args);
|
|
312
343
|
if (storeRegistry.has(name)) {
|
|
313
344
|
return {
|
|
314
345
|
content: [
|
|
@@ -319,10 +350,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
319
350
|
],
|
|
320
351
|
};
|
|
321
352
|
}
|
|
353
|
+
// A file-backed store writes to disk, which is an effect. Refuse to
|
|
354
|
+
// register one unless effects are explicitly enabled, so a sandboxed
|
|
355
|
+
// (dryRun) server can't be coaxed into persisting data.
|
|
356
|
+
if (!isStoreTypeAllowed(type, serverConfig.allowEffects ?? false)) {
|
|
357
|
+
return {
|
|
358
|
+
isError: true,
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: 'text',
|
|
362
|
+
text: `File-backed stores are disabled in sandboxed mode. Start the server with --allow-effects to register a "file" store, or use type "memory".`,
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Confine a file store's base directory to the working directory.
|
|
368
|
+
const baseDir = resolveWithinWorkingDir(path ?? '.reqon-data');
|
|
322
369
|
const store = createStore({
|
|
323
370
|
type,
|
|
324
371
|
name,
|
|
325
|
-
baseDir
|
|
372
|
+
baseDir,
|
|
326
373
|
});
|
|
327
374
|
storeRegistry.set(name, store);
|
|
328
375
|
return {
|
|
@@ -436,11 +483,17 @@ async function main() {
|
|
|
436
483
|
serverConfig.workingDirectory = args[++i];
|
|
437
484
|
process.chdir(serverConfig.workingDirectory);
|
|
438
485
|
}
|
|
486
|
+
if (args[i] === '--allow-effects') {
|
|
487
|
+
serverConfig.allowEffects = true;
|
|
488
|
+
}
|
|
439
489
|
}
|
|
440
490
|
const transport = new StdioServerTransport();
|
|
441
491
|
await server.connect(transport);
|
|
442
492
|
// Log to stderr so it doesn't interfere with MCP protocol on stdout
|
|
443
493
|
console.error('Reqon MCP Server running on stdio');
|
|
494
|
+
console.error(serverConfig.allowEffects
|
|
495
|
+
? ' Effects ENABLED (--allow-effects): missions may make real network/filesystem calls.'
|
|
496
|
+
: ' Sandboxed: missions run in dryRun (no network/filesystem effects). Pass --allow-effects to enable.');
|
|
444
497
|
if (serverConfig.verbose) {
|
|
445
498
|
console.error(` Working directory: ${serverConfig.workingDirectory}`);
|
|
446
499
|
}
|
package/dist/oas/loader.d.ts
CHANGED
|
@@ -15,7 +15,19 @@ export interface OASSource {
|
|
|
15
15
|
operations: Map<string, OASOperation>;
|
|
16
16
|
schemas: Map<string, OpenAPIV3.SchemaObject>;
|
|
17
17
|
}
|
|
18
|
-
export
|
|
18
|
+
export interface LoadOASOptions {
|
|
19
|
+
/** Bypass the spec cache and re-parse. */
|
|
20
|
+
forceReload?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Allow `$ref` pointers to external documents, including remote http(s)
|
|
23
|
+
* URLs. Off by default: an OAS spec is often untrusted input, and resolving
|
|
24
|
+
* external $refs lets the parser fetch arbitrary hosts/files — an SSRF and
|
|
25
|
+
* resource-exhaustion sink. When false, only internal `#/...` references are
|
|
26
|
+
* resolved; external ones are not fetched.
|
|
27
|
+
*/
|
|
28
|
+
allowExternalRefs?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function loadOAS(specPath: string, optionsOrForceReload?: LoadOASOptions | boolean): Promise<OASSource>;
|
|
19
31
|
export declare function resolveOperation(source: OASSource, operationId: string): OASOperation;
|
|
20
32
|
export declare function getResponseSchema(source: OASSource, operationId: string, statusCode?: string): OpenAPIV3.SchemaObject | undefined;
|
|
21
33
|
export declare function clearCache(): void;
|
package/dist/oas/loader.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
2
|
// Cache loaded specs to avoid re-parsing
|
|
3
3
|
const specCache = new Map();
|
|
4
|
-
export async function loadOAS(specPath,
|
|
4
|
+
export async function loadOAS(specPath, optionsOrForceReload = {}) {
|
|
5
|
+
// Back-compat: a bare boolean used to mean `forceReload`.
|
|
6
|
+
const options = typeof optionsOrForceReload === 'boolean'
|
|
7
|
+
? { forceReload: optionsOrForceReload }
|
|
8
|
+
: optionsOrForceReload;
|
|
9
|
+
const { forceReload = false, allowExternalRefs = false } = options;
|
|
5
10
|
if (!forceReload && specCache.has(specPath)) {
|
|
6
11
|
return specCache.get(specPath);
|
|
7
12
|
}
|
|
8
|
-
|
|
13
|
+
// Default-deny external reference resolution to prevent SSRF / remote fetches.
|
|
14
|
+
const parserOptions = allowExternalRefs ? {} : { resolve: { external: false } };
|
|
15
|
+
const api = (await SwaggerParser.dereference(specPath, parserOptions));
|
|
9
16
|
const baseUrl = extractBaseUrl(api);
|
|
10
17
|
const operations = extractOperations(api);
|
|
11
18
|
const schemas = extractSchemas(api);
|
|
@@ -20,10 +27,25 @@ export async function loadOAS(specPath, forceReload = false) {
|
|
|
20
27
|
}
|
|
21
28
|
function extractBaseUrl(spec) {
|
|
22
29
|
if (spec.servers && spec.servers.length > 0) {
|
|
23
|
-
return spec.servers[0]
|
|
30
|
+
return resolveServerUrl(spec.servers[0]);
|
|
24
31
|
}
|
|
25
32
|
return '';
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve `{variable}` templating in a server URL using each variable's
|
|
36
|
+
* declared default. A template variable with no default is rejected rather
|
|
37
|
+
* than left raw (a raw `{var}` would otherwise be sent as part of request
|
|
38
|
+
* URLs).
|
|
39
|
+
*/
|
|
40
|
+
function resolveServerUrl(server) {
|
|
41
|
+
return server.url.replace(/\{([^{}]+)\}/g, (_match, name) => {
|
|
42
|
+
const variable = server.variables?.[name];
|
|
43
|
+
if (variable && variable.default !== undefined && variable.default !== '') {
|
|
44
|
+
return String(variable.default);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`OAS server URL variable '{${name}}' has no default value (in "${server.url}")`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
27
49
|
function extractOperations(spec) {
|
|
28
50
|
const operations = new Map();
|
|
29
51
|
if (!spec.paths)
|