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.
- package/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- 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
|
+
});
|