keystone-cli 0.1.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.
Files changed (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. package/src/utils/workflow-registry.ts +121 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Shell command executor
3
+ *
4
+ * ⚠️ SECURITY WARNING:
5
+ * This executor runs shell commands using `sh -c`, which means:
6
+ * - User inputs interpolated into commands can lead to command injection
7
+ * - Malicious inputs like `foo; rm -rf /` will execute multiple commands
8
+ *
9
+ * IMPORTANT: Only run workflows from trusted sources.
10
+ * Commands are executed with the same privileges as the Keystone process.
11
+ * Expression evaluation happens before shell execution, so expressions
12
+ * like ${{ inputs.filename }} are evaluated first, then passed to the shell.
13
+ *
14
+ * ✅ RECOMMENDED PRACTICE:
15
+ * Use the escape() function to safely interpolate user inputs:
16
+ *
17
+ * steps:
18
+ * - id: safe_echo
19
+ * type: shell
20
+ * run: echo ${{ escape(inputs.user_message) }}
21
+ *
22
+ * The escape() function wraps arguments in single quotes and escapes any
23
+ * single quotes within, preventing command injection attacks.
24
+ *
25
+ * See SECURITY.md for more details.
26
+ */
27
+
28
+ import { $ } from 'bun';
29
+ import type { ExpressionContext } from '../expression/evaluator.ts';
30
+ import { ExpressionEvaluator } from '../expression/evaluator.ts';
31
+ import type { ShellStep } from '../parser/schema.ts';
32
+ import type { Logger } from './workflow-runner.ts';
33
+
34
+ /**
35
+ * Escape a shell argument for safe use in shell commands
36
+ * Wraps the argument in single quotes and escapes any single quotes within
37
+ *
38
+ * Example usage in workflows:
39
+ * ```yaml
40
+ * steps:
41
+ * - id: safe_echo
42
+ * type: shell
43
+ * # Use this pattern to safely interpolate user inputs:
44
+ * run: echo ${{ inputs.message }} # Safe: expression evaluation happens first
45
+ * # Avoid patterns like: sh -c "echo $USER_INPUT" where USER_INPUT is raw
46
+ * ```
47
+ */
48
+ export function escapeShellArg(arg: string): string {
49
+ // Replace single quotes with '\'' (end quote, escaped quote, start quote)
50
+ return `'${arg.replace(/'/g, "'\\''")}'`;
51
+ }
52
+
53
+ export interface ShellResult {
54
+ stdout: string;
55
+ stderr: string;
56
+ exitCode: number;
57
+ }
58
+
59
+ /**
60
+ * Check if a command contains potentially dangerous shell metacharacters
61
+ * Returns true if the command looks like it might contain unescaped user input
62
+ */
63
+ function detectShellInjectionRisk(command: string): boolean {
64
+ // Common shell metacharacters that indicate potential injection
65
+ const dangerousPatterns = [
66
+ /;[\s]*\w/, // Command chaining with semicolon
67
+ /\|[\s]*\w/, // Piping (legitimate uses exist, but worth warning)
68
+ /&&[\s]*\w/, // AND chaining
69
+ /\|\|[\s]*\w/, // OR chaining
70
+ /`[^`]+`/, // Command substitution with backticks
71
+ /\$\([^)]+\)/, // Command substitution with $()
72
+ />\s*\/dev\/null/, // Output redirection (common in attacks)
73
+ /rm\s+-rf/, // Dangerous deletion command
74
+ />\s*[~\/]/, // File redirection to suspicious paths
75
+ /curl\s+.*\|\s*sh/, // Download and execute pattern
76
+ /wget\s+.*\|\s*sh/, // Download and execute pattern
77
+ ];
78
+
79
+ return dangerousPatterns.some((pattern) => pattern.test(command));
80
+ }
81
+
82
+ /**
83
+ * Execute a shell command using Bun.$
84
+ */
85
+ export async function executeShell(
86
+ step: ShellStep,
87
+ context: ExpressionContext,
88
+ logger: Logger = console
89
+ ): Promise<ShellResult> {
90
+ // Evaluate the command string
91
+ const command = ExpressionEvaluator.evaluate(step.run, context) as string;
92
+
93
+ // Check for potential shell injection risks
94
+ if (detectShellInjectionRisk(command)) {
95
+ logger.warn(
96
+ `\n⚠️ WARNING: Command contains shell metacharacters that may indicate injection risk:\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To safely interpolate user inputs, use the escape() function.\n Example: run: echo \${{ escape(inputs.user_input) }}\n`
97
+ );
98
+ }
99
+
100
+ // Evaluate environment variables
101
+ const env: Record<string, string> = {};
102
+ if (step.env) {
103
+ for (const [key, value] of Object.entries(step.env)) {
104
+ env[key] = ExpressionEvaluator.evaluate(value, context) as string;
105
+ }
106
+ }
107
+
108
+ // Set working directory if specified
109
+ const cwd = step.dir ? (ExpressionEvaluator.evaluate(step.dir, context) as string) : undefined;
110
+
111
+ try {
112
+ // Execute command using sh -c to allow shell parsing
113
+ let proc = $`sh -c ${command}`.quiet();
114
+
115
+ // Apply environment variables - merge with Bun.env to preserve system PATH and other variables
116
+ if (Object.keys(env).length > 0) {
117
+ proc = proc.env({ ...Bun.env, ...env });
118
+ }
119
+
120
+ // Apply working directory
121
+ if (cwd) {
122
+ proc = proc.cwd(cwd);
123
+ }
124
+
125
+ // Execute and capture result
126
+ const result = await proc;
127
+
128
+ const stdout = await result.text();
129
+ const stderr = result.stderr ? result.stderr.toString() : '';
130
+ const exitCode = result.exitCode;
131
+
132
+ return {
133
+ stdout,
134
+ stderr,
135
+ exitCode,
136
+ };
137
+ } catch (error) {
138
+ // Handle shell execution errors (Bun throws ShellError with exitCode, stdout, stderr)
139
+ if (error && typeof error === 'object' && 'exitCode' in error) {
140
+ const shellError = error as {
141
+ exitCode: number;
142
+ stdout?: Buffer | string;
143
+ stderr?: Buffer | string;
144
+ };
145
+
146
+ // Convert stdout/stderr to strings if they're buffers
147
+ const stdout = shellError.stdout
148
+ ? Buffer.isBuffer(shellError.stdout)
149
+ ? shellError.stdout.toString()
150
+ : String(shellError.stdout)
151
+ : '';
152
+ const stderr = shellError.stderr
153
+ ? Buffer.isBuffer(shellError.stderr)
154
+ ? shellError.stderr.toString()
155
+ : String(shellError.stderr)
156
+ : '';
157
+
158
+ return {
159
+ stdout,
160
+ stderr,
161
+ exitCode: shellError.exitCode,
162
+ };
163
+ }
164
+ throw error;
165
+ }
166
+ }
@@ -0,0 +1,465 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
2
+ import { mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import type { ExpressionContext } from '../expression/evaluator';
5
+ import type {
6
+ FileStep,
7
+ HumanStep,
8
+ RequestStep,
9
+ ShellStep,
10
+ SleepStep,
11
+ WorkflowStep,
12
+ } from '../parser/schema';
13
+ import { executeStep } from './step-executor';
14
+
15
+ // Mock executeLlmStep
16
+ mock.module('./llm-executor', () => ({
17
+ // @ts-ignore
18
+ executeLlmStep: mock((_step, _context, _callback) => {
19
+ return Promise.resolve({ status: 'success', output: 'llm-output' });
20
+ }),
21
+ }));
22
+
23
+ interface StepOutput {
24
+ stdout: string;
25
+ stderr: string;
26
+ exitCode: number;
27
+ }
28
+
29
+ interface RequestOutput {
30
+ status: number;
31
+ data: unknown;
32
+ }
33
+
34
+ // Mock node:readline/promises
35
+ const mockRl = {
36
+ question: mock(() => Promise.resolve('')),
37
+ close: mock(() => {}),
38
+ };
39
+
40
+ mock.module('node:readline/promises', () => ({
41
+ createInterface: mock(() => mockRl),
42
+ }));
43
+
44
+ describe('step-executor', () => {
45
+ let context: ExpressionContext;
46
+
47
+ const tempDir = join(process.cwd(), 'temp-step-test');
48
+
49
+ beforeAll(() => {
50
+ try {
51
+ mkdirSync(tempDir, { recursive: true });
52
+ } catch (e) {}
53
+ });
54
+
55
+ afterAll(() => {
56
+ try {
57
+ rmSync(tempDir, { recursive: true, force: true });
58
+ } catch (e) {}
59
+ });
60
+
61
+ beforeEach(() => {
62
+ context = {
63
+ inputs: {},
64
+ steps: {},
65
+ };
66
+ });
67
+
68
+ describe('shell', () => {
69
+ it('should execute shell command', async () => {
70
+ const step: ShellStep = {
71
+ id: 's1',
72
+ type: 'shell',
73
+ run: 'echo "hello"',
74
+ };
75
+ const result = await executeStep(step, context);
76
+ expect(result.status).toBe('success');
77
+ expect((result.output as StepOutput).stdout.trim()).toBe('hello');
78
+ });
79
+
80
+ it('should handle shell failure', async () => {
81
+ const step: ShellStep = {
82
+ id: 's1',
83
+ type: 'shell',
84
+ run: 'exit 1',
85
+ };
86
+ const result = await executeStep(step, context);
87
+ expect(result.status).toBe('failed');
88
+ expect(result.error).toContain('exited with code 1');
89
+ });
90
+ });
91
+
92
+ describe('file', () => {
93
+ it('should write and read a file', async () => {
94
+ const filePath = join(tempDir, 'test.txt');
95
+ const writeStep: FileStep = {
96
+ id: 'w1',
97
+ type: 'file',
98
+ op: 'write',
99
+ path: filePath,
100
+ content: 'hello file',
101
+ };
102
+ const writeResult = await executeStep(writeStep, context);
103
+ expect(writeResult.status).toBe('success');
104
+
105
+ const readStep: FileStep = {
106
+ id: 'r1',
107
+ type: 'file',
108
+ op: 'read',
109
+ path: filePath,
110
+ };
111
+ const readResult = await executeStep(readStep, context);
112
+ expect(readResult.status).toBe('success');
113
+ expect(readResult.output).toBe('hello file');
114
+ });
115
+
116
+ it('should append to a file', async () => {
117
+ const filePath = join(tempDir, 'append.txt');
118
+ await executeStep(
119
+ {
120
+ id: 'w1',
121
+ type: 'file',
122
+ op: 'write',
123
+ path: filePath,
124
+ content: 'line 1\n',
125
+ } as FileStep,
126
+ context
127
+ );
128
+
129
+ await executeStep(
130
+ {
131
+ id: 'a1',
132
+ type: 'file',
133
+ op: 'append',
134
+ path: filePath,
135
+ content: 'line 2',
136
+ } as FileStep,
137
+ context
138
+ );
139
+
140
+ const content = await Bun.file(filePath).text();
141
+ expect(content).toBe('line 1\nline 2');
142
+ });
143
+
144
+ it('should fail if file not found on read', async () => {
145
+ const readStep: FileStep = {
146
+ id: 'r1',
147
+ type: 'file',
148
+ op: 'read',
149
+ path: join(tempDir, 'non-existent.txt'),
150
+ };
151
+ const result = await executeStep(readStep, context);
152
+ expect(result.status).toBe('failed');
153
+ expect(result.error).toContain('File not found');
154
+ });
155
+
156
+ it('should fail if content missing on write', async () => {
157
+ // @ts-ignore
158
+ const step: FileStep = { id: 'f1', type: 'file', op: 'write', path: 'test.txt' };
159
+ const result = await executeStep(step, context);
160
+ expect(result.status).toBe('failed');
161
+ expect(result.error).toBe('Content is required for write operation');
162
+ });
163
+
164
+ it('should fail if content missing on append', async () => {
165
+ // @ts-ignore
166
+ const step: FileStep = { id: 'f1', type: 'file', op: 'append', path: 'test.txt' };
167
+ const result = await executeStep(step, context);
168
+ expect(result.status).toBe('failed');
169
+ expect(result.error).toBe('Content is required for append operation');
170
+ });
171
+
172
+ it('should fail for unknown file operation', async () => {
173
+ // @ts-ignore
174
+ const step: FileStep = { id: 'f1', type: 'file', op: 'unknown', path: 'test.txt' };
175
+ const result = await executeStep(step, context);
176
+ expect(result.status).toBe('failed');
177
+ expect(result.error).toContain('Unknown file operation');
178
+ });
179
+ });
180
+
181
+ describe('sleep', () => {
182
+ it('should sleep for a duration', async () => {
183
+ const step: SleepStep = {
184
+ id: 'sl1',
185
+ type: 'sleep',
186
+ duration: 10,
187
+ };
188
+ const start = Date.now();
189
+ const result = await executeStep(step, context);
190
+ const end = Date.now();
191
+ expect(result.status).toBe('success');
192
+ expect(end - start).toBeGreaterThanOrEqual(10);
193
+ });
194
+ });
195
+
196
+ describe('request', () => {
197
+ const originalFetch = global.fetch;
198
+
199
+ beforeEach(() => {
200
+ // @ts-ignore
201
+ global.fetch = mock();
202
+ });
203
+
204
+ afterEach(() => {
205
+ global.fetch = originalFetch;
206
+ });
207
+
208
+ it('should perform an HTTP request', async () => {
209
+ // @ts-ignore
210
+ global.fetch.mockResolvedValue(
211
+ new Response('{"ok":true}', {
212
+ status: 200,
213
+ headers: { 'Content-Type': 'application/json' },
214
+ })
215
+ );
216
+
217
+ const step: RequestStep = {
218
+ id: 'req1',
219
+ type: 'request',
220
+ url: 'https://api.example.com/test',
221
+ method: 'GET',
222
+ };
223
+
224
+ const result = await executeStep(step, context);
225
+ expect(result.status).toBe('success');
226
+ expect((result.output as RequestOutput).data).toEqual({ ok: true });
227
+ });
228
+
229
+ it('should handle form-urlencoded body', async () => {
230
+ // @ts-ignore
231
+ global.fetch.mockResolvedValue(new Response('ok'));
232
+
233
+ const step: RequestStep = {
234
+ id: 'req1',
235
+ type: 'request',
236
+ url: 'https://api.example.com/post',
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
239
+ body: { key: 'value', foo: 'bar' },
240
+ };
241
+
242
+ await executeStep(step, context);
243
+
244
+ // @ts-ignore
245
+ const init = global.fetch.mock.calls[0][1];
246
+ expect(init.body).toBe('key=value&foo=bar');
247
+ });
248
+
249
+ it('should handle non-object body in form-urlencoded', async () => {
250
+ // @ts-ignore
251
+ global.fetch.mockResolvedValue(new Response('ok'));
252
+
253
+ const step: RequestStep = {
254
+ id: 'req1',
255
+ type: 'request',
256
+ url: 'https://api.example.com/post',
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
259
+ body: 'raw-body',
260
+ };
261
+
262
+ await executeStep(step, context);
263
+
264
+ // @ts-ignore
265
+ const init = global.fetch.mock.calls[0][1];
266
+ expect(init.body).toBe('raw-body');
267
+ });
268
+
269
+ it('should auto-set JSON content type for object bodies', async () => {
270
+ // @ts-ignore
271
+ global.fetch.mockResolvedValue(new Response('{}'));
272
+
273
+ const step: RequestStep = {
274
+ id: 'req1',
275
+ type: 'request',
276
+ url: 'https://api.example.com/post',
277
+ method: 'POST',
278
+ body: { foo: 'bar' },
279
+ };
280
+
281
+ await executeStep(step, context);
282
+
283
+ // @ts-ignore
284
+ const init = global.fetch.mock.calls[0][1];
285
+ expect(init.headers['Content-Type']).toBe('application/json');
286
+ expect(init.body).toBe('{"foo":"bar"}');
287
+ });
288
+
289
+ it('should handle non-JSON responses', async () => {
290
+ // @ts-ignore
291
+ global.fetch.mockResolvedValue(
292
+ new Response('plain text', {
293
+ status: 200,
294
+ headers: { 'Content-Type': 'text/plain' },
295
+ })
296
+ );
297
+
298
+ const step: RequestStep = {
299
+ id: 'req1',
300
+ type: 'request',
301
+ url: 'https://api.example.com/text',
302
+ method: 'GET',
303
+ };
304
+
305
+ const result = await executeStep(step, context);
306
+ // @ts-ignore
307
+ expect(result.output.data).toBe('plain text');
308
+ });
309
+ });
310
+
311
+ describe('human', () => {
312
+ const originalIsTTY = process.stdin.isTTY;
313
+
314
+ beforeEach(() => {
315
+ process.stdin.isTTY = true;
316
+ });
317
+
318
+ afterEach(() => {
319
+ process.stdin.isTTY = originalIsTTY;
320
+ });
321
+
322
+ it('should handle human confirmation', async () => {
323
+ mockRl.question.mockResolvedValue('\n');
324
+
325
+ const step: HumanStep = {
326
+ id: 'h1',
327
+ type: 'human',
328
+ message: 'Proceed?',
329
+ inputType: 'confirm',
330
+ };
331
+
332
+ // @ts-ignore
333
+ const result = await executeStep(step, context, { log: () => {} });
334
+ expect(result.status).toBe('success');
335
+ expect(result.output).toBe(true);
336
+ expect(mockRl.question).toHaveBeenCalled();
337
+ });
338
+
339
+ it('should handle human text input', async () => {
340
+ mockRl.question.mockResolvedValue('user response');
341
+
342
+ const step: HumanStep = {
343
+ id: 'h1',
344
+ type: 'human',
345
+ message: 'What is your name?',
346
+ inputType: 'text',
347
+ };
348
+
349
+ // @ts-ignore
350
+ const result = await executeStep(step, context, { log: () => {} });
351
+ expect(result.status).toBe('success');
352
+ expect(result.output).toBe('user response');
353
+ });
354
+
355
+ it('should suspend if not a TTY', async () => {
356
+ process.stdin.isTTY = false;
357
+
358
+ const step: HumanStep = {
359
+ id: 'h1',
360
+ type: 'human',
361
+ message: 'Proceed?',
362
+ inputType: 'confirm',
363
+ };
364
+
365
+ // @ts-ignore
366
+ const result = await executeStep(step, context, { log: () => {} });
367
+ expect(result.status).toBe('suspended');
368
+ expect(result.error).toBe('Proceed?');
369
+ });
370
+ });
371
+
372
+ describe('workflow', () => {
373
+ it('should call executeWorkflowFn', async () => {
374
+ const step: WorkflowStep = {
375
+ id: 'w1',
376
+ type: 'workflow',
377
+ workflow: 'child.yaml',
378
+ };
379
+ // @ts-ignore
380
+ const executeWorkflowFn = mock(() =>
381
+ Promise.resolve({ status: 'success', output: 'child-output' })
382
+ );
383
+
384
+ // @ts-ignore
385
+ const result = await executeStep(step, context, undefined, executeWorkflowFn);
386
+ expect(result.status).toBe('success');
387
+ expect(result.output).toBe('child-output');
388
+ expect(executeWorkflowFn).toHaveBeenCalled();
389
+ });
390
+
391
+ it('should fail if executeWorkflowFn is not provided', async () => {
392
+ const step: WorkflowStep = {
393
+ id: 'w1',
394
+ type: 'workflow',
395
+ workflow: 'child.yaml',
396
+ };
397
+ const result = await executeStep(step, context);
398
+ expect(result.status).toBe('failed');
399
+ expect(result.error).toContain('Workflow executor not provided');
400
+ });
401
+ });
402
+
403
+ describe('llm', () => {
404
+ it('should call executeLlmStep', async () => {
405
+ // @ts-ignore
406
+ const step = {
407
+ id: 'l1',
408
+ type: 'llm',
409
+ prompt: 'hello',
410
+ };
411
+ const result = await executeStep(step, context);
412
+ expect(result.status).toBe('success');
413
+ expect(result.output).toBe('llm-output');
414
+ });
415
+ });
416
+
417
+ describe('transform', () => {
418
+ it('should apply transform to output', async () => {
419
+ const step: ShellStep = {
420
+ id: 's1',
421
+ type: 'shell',
422
+ run: 'echo "json string"',
423
+ transform: 'output.stdout.toUpperCase().trim()',
424
+ };
425
+ const result = await executeStep(step, context);
426
+ expect(result.status).toBe('success');
427
+ expect(result.output).toBe('JSON STRING');
428
+ });
429
+
430
+ it('should apply transform with ${{ }} syntax', async () => {
431
+ const step: ShellStep = {
432
+ id: 's1',
433
+ type: 'shell',
434
+ run: 'echo "hello"',
435
+ transform: '${{ output.stdout.trim() + " world" }}',
436
+ };
437
+ const result = await executeStep(step, context);
438
+ expect(result.status).toBe('success');
439
+ expect(result.output).toBe('hello world');
440
+ });
441
+
442
+ it('should handle transform failure', async () => {
443
+ const step: ShellStep = {
444
+ id: 's1',
445
+ type: 'shell',
446
+ run: 'echo "hello"',
447
+ transform: 'nonexistent.property',
448
+ };
449
+ const result = await executeStep(step, context);
450
+ expect(result.status).toBe('failed');
451
+ expect(result.error).toContain('Transform failed');
452
+ });
453
+ });
454
+
455
+ it('should throw error for unknown step type', async () => {
456
+ // @ts-ignore
457
+ const step = {
458
+ id: 'u1',
459
+ type: 'unknown',
460
+ };
461
+ const result = await executeStep(step, context);
462
+ expect(result.status).toBe('failed');
463
+ expect(result.error).toContain('Unknown step type');
464
+ });
465
+ });