keystone-cli 1.0.3 → 1.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 +276 -32
- package/package.json +8 -4
- package/src/cli.ts +350 -416
- package/src/commands/doc.ts +31 -0
- package/src/commands/event.ts +29 -0
- package/src/commands/graph.ts +37 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/init.ts +185 -0
- package/src/commands/run.ts +124 -0
- package/src/commands/schema.ts +40 -0
- package/src/commands/utils.ts +78 -0
- package/src/commands/validate.ts +111 -0
- package/src/db/workflow-db.test.ts +314 -0
- package/src/db/workflow-db.ts +810 -210
- package/src/expression/evaluator-audit.test.ts +4 -2
- package/src/expression/evaluator.test.ts +14 -1
- package/src/expression/evaluator.ts +166 -19
- package/src/parser/config-schema.ts +18 -0
- package/src/parser/schema.ts +153 -22
- package/src/parser/test-schema.ts +6 -6
- package/src/parser/workflow-parser.test.ts +24 -0
- package/src/parser/workflow-parser.ts +65 -3
- package/src/runner/auto-heal.test.ts +5 -6
- package/src/runner/blueprint-executor.test.ts +2 -2
- package/src/runner/debug-repl.test.ts +5 -8
- package/src/runner/debug-repl.ts +59 -16
- package/src/runner/durable-timers.test.ts +11 -2
- package/src/runner/engine-executor.test.ts +1 -1
- package/src/runner/events.ts +57 -0
- package/src/runner/executors/artifact-executor.ts +166 -0
- package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
- package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
- package/src/runner/executors/file-executor.test.ts +48 -0
- package/src/runner/executors/file-executor.ts +324 -0
- package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
- package/src/runner/executors/human-executor.ts +144 -0
- package/src/runner/executors/join-executor.ts +75 -0
- package/src/runner/executors/llm-executor.ts +1266 -0
- package/src/runner/executors/memory-executor.ts +71 -0
- package/src/runner/executors/plan-executor.ts +104 -0
- package/src/runner/executors/request-executor.ts +265 -0
- package/src/runner/executors/script-executor.ts +43 -0
- package/src/runner/executors/shell-executor.ts +403 -0
- package/src/runner/executors/subworkflow-executor.ts +114 -0
- package/src/runner/executors/types.ts +69 -0
- package/src/runner/executors/wait-executor.ts +59 -0
- package/src/runner/join-scheduling.test.ts +197 -0
- package/src/runner/llm-adapter-runtime.test.ts +209 -0
- package/src/runner/llm-adapter.test.ts +419 -24
- package/src/runner/llm-adapter.ts +130 -26
- package/src/runner/llm-clarification.test.ts +2 -1
- package/src/runner/llm-executor.test.ts +532 -17
- package/src/runner/mcp-client-audit.test.ts +1 -2
- package/src/runner/mcp-client.ts +136 -46
- package/src/runner/mcp-manager.test.ts +4 -0
- package/src/runner/mcp-server.test.ts +58 -0
- package/src/runner/mcp-server.ts +26 -0
- package/src/runner/memoization.test.ts +190 -0
- package/src/runner/optimization-runner.ts +4 -9
- package/src/runner/quality-gate.test.ts +69 -0
- package/src/runner/reflexion.test.ts +6 -17
- package/src/runner/resource-pool.ts +102 -14
- package/src/runner/services/context-builder.ts +144 -0
- package/src/runner/services/secret-manager.ts +105 -0
- package/src/runner/services/workflow-validator.ts +131 -0
- package/src/runner/shell-executor.test.ts +28 -4
- package/src/runner/standard-tools-ast.test.ts +196 -0
- package/src/runner/standard-tools-execution.test.ts +27 -0
- package/src/runner/standard-tools-integration.test.ts +6 -10
- package/src/runner/standard-tools.ts +339 -102
- package/src/runner/step-executor.test.ts +216 -4
- package/src/runner/step-executor.ts +69 -941
- package/src/runner/stream-utils.ts +7 -3
- package/src/runner/test-harness.ts +20 -1
- package/src/runner/timeout.test.ts +10 -0
- package/src/runner/timeout.ts +11 -2
- package/src/runner/tool-integration.test.ts +1 -1
- package/src/runner/wait-step.test.ts +102 -0
- package/src/runner/workflow-runner.test.ts +208 -15
- package/src/runner/workflow-runner.ts +890 -818
- package/src/runner/workflow-scheduler.ts +75 -0
- package/src/runner/workflow-state.ts +269 -0
- package/src/runner/workflow-subflows.test.ts +13 -12
- package/src/scripts/generate-schemas.ts +16 -0
- package/src/templates/agents/explore.md +1 -0
- package/src/templates/agents/general.md +1 -0
- package/src/templates/agents/handoff-router.md +14 -0
- package/src/templates/agents/handoff-specialist.md +15 -0
- package/src/templates/agents/keystone-architect.md +13 -44
- package/src/templates/agents/my-agent.md +1 -0
- package/src/templates/agents/software-engineer.md +1 -0
- package/src/templates/agents/summarizer.md +1 -0
- package/src/templates/agents/test-agent.md +1 -0
- package/src/templates/agents/tester.md +1 -0
- package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
- package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
- package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
- package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
- package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
- package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
- package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
- package/src/templates/control-flow/idempotency-example.yaml +30 -0
- package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
- package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
- package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
- package/src/templates/features/artifact-example.yaml +39 -0
- package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
- package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
- package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
- package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
- package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
- package/src/templates/features/script-example.yaml +27 -0
- package/src/templates/patterns/agent-handoff.yaml +53 -0
- package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
- package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
- package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
- package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
- package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
- package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
- package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
- package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
- package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
- package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
- package/src/templates/scaffolding/review-loop.yaml +97 -0
- package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
- package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
- package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
- package/src/templates/testing/invalid.yaml +6 -0
- package/src/ui/dashboard.tsx +191 -33
- package/src/utils/auth-manager.test.ts +337 -0
- package/src/utils/auth-manager.ts +157 -61
- package/src/utils/blueprint-utils.ts +4 -6
- package/src/utils/config-loader.test.ts +2 -0
- package/src/utils/config-loader.ts +12 -3
- package/src/utils/constants.ts +76 -0
- package/src/utils/container.ts +63 -0
- package/src/utils/context-injector.test.ts +200 -0
- package/src/utils/context-injector.ts +244 -0
- package/src/utils/doc-generator.ts +85 -0
- package/src/utils/env-filter.ts +45 -0
- package/src/utils/json-parser.test.ts +12 -0
- package/src/utils/json-parser.ts +30 -5
- package/src/utils/logger.ts +12 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.ts +52 -1
- package/src/utils/process-sandbox-worker.test.ts +46 -0
- package/src/utils/process-sandbox.ts +227 -14
- package/src/utils/redactor.test.ts +11 -6
- package/src/utils/redactor.ts +25 -9
- package/src/utils/sandbox.ts +3 -0
- package/src/runner/llm-executor.ts +0 -638
- package/src/runner/shell-executor.ts +0 -366
- package/src/templates/invalid.yaml +0 -5
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
|
|
26
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
27
|
+
import { ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
28
|
+
import type { ShellStep } from '../../parser/schema.ts';
|
|
29
|
+
import { LIMITS } from '../../utils/constants.ts';
|
|
30
|
+
import { filterSensitiveEnv } from '../../utils/env-filter.ts';
|
|
31
|
+
import { ConsoleLogger, type Logger } from '../../utils/logger.ts';
|
|
32
|
+
import { PathResolver } from '../../utils/paths.ts';
|
|
33
|
+
import type { StepResult } from './types.ts';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute a shell step
|
|
37
|
+
*/
|
|
38
|
+
export async function executeShellStep(
|
|
39
|
+
step: ShellStep,
|
|
40
|
+
context: ExpressionContext,
|
|
41
|
+
logger: Logger,
|
|
42
|
+
dryRun?: boolean,
|
|
43
|
+
abortSignal?: AbortSignal
|
|
44
|
+
): Promise<StepResult> {
|
|
45
|
+
if (step.args) {
|
|
46
|
+
const command = step.args.map((a) => ExpressionEvaluator.evaluateString(a, context)).join(' ');
|
|
47
|
+
if (dryRun) {
|
|
48
|
+
logger.log(`[DRY RUN] Would execute: ${command}`);
|
|
49
|
+
return {
|
|
50
|
+
output: { stdout: '[DRY RUN] Success', stderr: '', exitCode: 0 },
|
|
51
|
+
status: 'success',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = await executeShellArgs(step.args, context, logger, abortSignal, step.dir);
|
|
56
|
+
return formatShellResult(result, logger);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!step.run) {
|
|
60
|
+
throw new Error('Shell step must have either "run" or "args"');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Strict Mode Check: Detect unescaped expressions in the raw template
|
|
64
|
+
// We check if there are any ${{ }} blocks that don't start with escape(
|
|
65
|
+
const hasUnescapedExpr = (s: string) => {
|
|
66
|
+
// Finds ${{ ... }} blocks
|
|
67
|
+
const matches = s.match(/\${{.*?}}/g);
|
|
68
|
+
if (!matches) return false;
|
|
69
|
+
|
|
70
|
+
// Check if the expression is strictly wrapped in escape(...)
|
|
71
|
+
// Matches: ${{ escape(...) }} or ${{ escape( ... ) }}
|
|
72
|
+
// Does NOT match: ${{ "foo" + escape(...) }}
|
|
73
|
+
return matches.some((m) => {
|
|
74
|
+
const content = m.slice(3, -2).trim(); // Remove ${{ and }}
|
|
75
|
+
return !/^escape\s*\(.*\)$/.test(content);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!step.allowInsecure && hasUnescapedExpr(step.run)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Security Error: Shell command contains unescaped expressions which are vulnerable to injection.\nUse \${{ escape(...) }} to safely interpolate values, or set 'allowInsecure: true' if you trust the source.\nCommand template: ${step.run}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const command = ExpressionEvaluator.evaluateString(step.run, context);
|
|
86
|
+
if (!step.allowInsecure && detectShellInjectionRisk(command)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Security Error: Evaluated command contains shell metacharacters that require 'allowInsecure: true'.\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n Metacharacters detected. Please use 'allowInsecure: true' if this is intended.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = await executeShell(step, context, logger, abortSignal, command);
|
|
93
|
+
return formatShellResult(result, logger);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format the internal ShellResult into a StepResult
|
|
98
|
+
*/
|
|
99
|
+
function formatShellResult(result: ShellResult, logger: Logger): StepResult {
|
|
100
|
+
if (result.stdout) {
|
|
101
|
+
logger.log(result.stdout.trim());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
return {
|
|
106
|
+
output: {
|
|
107
|
+
stdout: result.stdout,
|
|
108
|
+
stderr: result.stderr,
|
|
109
|
+
exitCode: result.exitCode,
|
|
110
|
+
stdoutTruncated: result.stdoutTruncated,
|
|
111
|
+
stderrTruncated: result.stderrTruncated,
|
|
112
|
+
},
|
|
113
|
+
status: 'failed',
|
|
114
|
+
error: `Shell command exited with code ${result.exitCode}: ${result.stderr}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
output: {
|
|
120
|
+
stdout: result.stdout,
|
|
121
|
+
stderr: result.stderr,
|
|
122
|
+
exitCode: result.exitCode,
|
|
123
|
+
stdoutTruncated: result.stdoutTruncated,
|
|
124
|
+
stderrTruncated: result.stderrTruncated,
|
|
125
|
+
},
|
|
126
|
+
status: 'success',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Escape a shell argument for safe use in shell commands
|
|
132
|
+
* Wraps the argument in single quotes and escapes any single quotes within
|
|
133
|
+
*
|
|
134
|
+
* Example usage in workflows:
|
|
135
|
+
* ```yaml
|
|
136
|
+
* steps:
|
|
137
|
+
* - id: safe_echo
|
|
138
|
+
* type: shell
|
|
139
|
+
* # Use this pattern to safely interpolate user inputs:
|
|
140
|
+
* run: echo ${{ escape(inputs.message) }} # Safe: explicitly escaped
|
|
141
|
+
* # Avoid patterns like: sh -c "echo $USER_INPUT" where USER_INPUT is raw
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export function escapeShellArg(arg: unknown): string {
|
|
145
|
+
const value = arg === null || arg === undefined ? '' : String(arg);
|
|
146
|
+
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
|
147
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ShellResult {
|
|
151
|
+
stdout: string;
|
|
152
|
+
stderr: string;
|
|
153
|
+
exitCode: number;
|
|
154
|
+
stdoutTruncated?: boolean;
|
|
155
|
+
stderrTruncated?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const TRUNCATED_SUFFIX = '... [truncated output]';
|
|
159
|
+
|
|
160
|
+
async function readStreamWithLimit(
|
|
161
|
+
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
162
|
+
maxBytes: number
|
|
163
|
+
): Promise<{ text: string; truncated: boolean }> {
|
|
164
|
+
if (!stream) return { text: '', truncated: false };
|
|
165
|
+
if (maxBytes <= 0) {
|
|
166
|
+
try {
|
|
167
|
+
await stream.cancel?.();
|
|
168
|
+
} catch {}
|
|
169
|
+
return { text: TRUNCATED_SUFFIX, truncated: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const reader = stream.getReader();
|
|
173
|
+
const decoder = new TextDecoder();
|
|
174
|
+
let text = '';
|
|
175
|
+
let bytesRead = 0;
|
|
176
|
+
|
|
177
|
+
while (true) {
|
|
178
|
+
const { value, done } = await reader.read();
|
|
179
|
+
if (done) break;
|
|
180
|
+
if (!value) continue;
|
|
181
|
+
|
|
182
|
+
if (bytesRead + value.byteLength > maxBytes) {
|
|
183
|
+
const allowed = maxBytes - bytesRead;
|
|
184
|
+
if (allowed > 0) {
|
|
185
|
+
text += decoder.decode(value.slice(0, allowed), { stream: true });
|
|
186
|
+
}
|
|
187
|
+
text += decoder.decode();
|
|
188
|
+
try {
|
|
189
|
+
await reader.cancel();
|
|
190
|
+
} catch {}
|
|
191
|
+
return { text: `${text}${TRUNCATED_SUFFIX}`, truncated: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
bytesRead += value.byteLength;
|
|
195
|
+
text += decoder.decode(value, { stream: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
text += decoder.decode();
|
|
199
|
+
return { text, truncated: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Whitelist of allowed characters for secure shell command execution
|
|
203
|
+
// Allows: Alphanumeric, whitespace, and common safe punctuation (_ . / : @ , + - = ' " !)
|
|
204
|
+
// Blocks: Backslashes, pipes, redirects, subshells, variables ($), etc.
|
|
205
|
+
const SAFE_SHELL_CHARS = /^[a-zA-Z0-9\s_./:@,+=~'"!-]+$/;
|
|
206
|
+
|
|
207
|
+
export function detectShellInjectionRisk(rawCommand: string): boolean {
|
|
208
|
+
// If the command contains any character NOT in the whitelist, it's considered risky
|
|
209
|
+
return !SAFE_SHELL_CHARS.test(rawCommand);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Execute a shell command using Bun.spawn
|
|
214
|
+
*/
|
|
215
|
+
export async function executeShell(
|
|
216
|
+
step: ShellStep,
|
|
217
|
+
context: ExpressionContext,
|
|
218
|
+
logger: Logger = new ConsoleLogger(),
|
|
219
|
+
abortSignal?: AbortSignal,
|
|
220
|
+
commandOverride?: string
|
|
221
|
+
): Promise<ShellResult> {
|
|
222
|
+
if (abortSignal?.aborted) {
|
|
223
|
+
throw new Error('Step canceled');
|
|
224
|
+
}
|
|
225
|
+
// Evaluate the command string
|
|
226
|
+
const command = commandOverride ?? ExpressionEvaluator.evaluateString(step.run, context);
|
|
227
|
+
|
|
228
|
+
// Security Check: Enforce whitelist
|
|
229
|
+
// If we haven't enabled insecure mode, we MUST be able to use spawn (no shell)
|
|
230
|
+
// or the command must be strictly composed of safe characters.
|
|
231
|
+
if (!step.allowInsecure) {
|
|
232
|
+
if (detectShellInjectionRisk(command)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Security Error: Command execution blocked.\nCommand: "${command.substring(0, 100)}${
|
|
235
|
+
command.length > 100 ? '...' : ''
|
|
236
|
+
}"\nReason: Contains characters not in the strict whitelist (alphanumeric, whitespace, and _./:@,+=~-).\nThis protects against shell injection attacks.\nFix: either simplify your command or set 'allowInsecure: true' in your step definition if you trust the input.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Evaluate environment variables
|
|
242
|
+
const env: Record<string, string> = context.env ? { ...context.env } : {};
|
|
243
|
+
if (step.env) {
|
|
244
|
+
for (const [key, value] of Object.entries(step.env)) {
|
|
245
|
+
env[key] = ExpressionEvaluator.evaluateString(value, context);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Set working directory if specified
|
|
250
|
+
const cwd = step.dir ? ExpressionEvaluator.evaluateString(step.dir, context) : undefined;
|
|
251
|
+
if (cwd) {
|
|
252
|
+
PathResolver.assertWithinCwd(cwd, step.allowOutsideCwd, 'Directory');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const hostEnv = filterSensitiveEnv(Bun.env);
|
|
256
|
+
const mergedEnv = Object.keys(env).length > 0 ? { ...hostEnv, ...env } : hostEnv;
|
|
257
|
+
|
|
258
|
+
// If secure (whitelist passed) OR insecure mode is explicitly allowed...
|
|
259
|
+
// We prefer direct spawn if possible, but fall back to shell if needed (e.g. for pipelines in insecure mode)
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// If we are in secure mode (allowInsecure: false), we KNOW the command is safe.
|
|
263
|
+
// However, it might still benefit from running directly via spawn to avoid even theoretical shell issues.
|
|
264
|
+
// But simplified splitting by space might break if we allowed quotes (which we don't in the whitelist).
|
|
265
|
+
|
|
266
|
+
// For now, if insecure is allowed, we use 'sh -c'.
|
|
267
|
+
// If secure (whitelist valid), we can also use 'sh -c' relatively safely, or split and spawn.
|
|
268
|
+
// Using 'sh -c' is robust for arguments. Since we validated the string against a strict whitelist,
|
|
269
|
+
// 'sh -c' shouldn't be able to do anything funky like variable expansion or subshells because appropriate chars are banned.
|
|
270
|
+
|
|
271
|
+
let stdoutString = '';
|
|
272
|
+
let stderrString = '';
|
|
273
|
+
let exitCode = 0;
|
|
274
|
+
let stdoutTruncated = false;
|
|
275
|
+
let stderrTruncated = false;
|
|
276
|
+
const maxOutputBytes = LIMITS.MAX_PROCESS_OUTPUT_BYTES;
|
|
277
|
+
|
|
278
|
+
// Use 'sh -c' for everything to ensure consistent argument parsing
|
|
279
|
+
// Security is guaranteed by the strict whitelist check above for allowInsecure: false
|
|
280
|
+
// which prevents injection of metacharacters, quotes, escapes, etc.
|
|
281
|
+
const proc = Bun.spawn(['sh', '-c', command], {
|
|
282
|
+
cwd: cwd || process.cwd(),
|
|
283
|
+
env: mergedEnv,
|
|
284
|
+
stdout: 'pipe',
|
|
285
|
+
stderr: 'pipe',
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const abortHandler = () => {
|
|
289
|
+
try {
|
|
290
|
+
proc.kill();
|
|
291
|
+
} catch {}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (abortSignal) {
|
|
295
|
+
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const stdoutPromise = readStreamWithLimit(proc.stdout, maxOutputBytes);
|
|
299
|
+
const stderrPromise = readStreamWithLimit(proc.stderr, maxOutputBytes);
|
|
300
|
+
|
|
301
|
+
// Wait for exit
|
|
302
|
+
exitCode = await proc.exited;
|
|
303
|
+
const [stdoutResult, stderrResult] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
304
|
+
|
|
305
|
+
stdoutString = stdoutResult.text;
|
|
306
|
+
stderrString = stderrResult.text;
|
|
307
|
+
stdoutTruncated = stdoutResult.truncated;
|
|
308
|
+
stderrTruncated = stderrResult.truncated;
|
|
309
|
+
|
|
310
|
+
if (abortSignal) {
|
|
311
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
stdout: stdoutString,
|
|
316
|
+
stderr: stderrString,
|
|
317
|
+
exitCode,
|
|
318
|
+
stdoutTruncated,
|
|
319
|
+
stderrTruncated,
|
|
320
|
+
};
|
|
321
|
+
} catch (error) {
|
|
322
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
323
|
+
if (msg === 'Step canceled') {
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Handle specific Bun shell errors if they occur
|
|
328
|
+
if (error && typeof error === 'object' && 'exitCode' in error) {
|
|
329
|
+
const shellError = error as { exitCode: number; stdout: any; stderr: any };
|
|
330
|
+
return {
|
|
331
|
+
stdout: String(shellError.stdout || ''),
|
|
332
|
+
stderr: String(shellError.stderr || ''),
|
|
333
|
+
exitCode: shellError.exitCode,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Generic error handling
|
|
338
|
+
return {
|
|
339
|
+
stdout: '',
|
|
340
|
+
stderr: msg,
|
|
341
|
+
exitCode: 1,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Execute a command directly without a shell using an argument array
|
|
348
|
+
*/
|
|
349
|
+
export async function executeShellArgs(
|
|
350
|
+
argsTemplates: string[],
|
|
351
|
+
context: ExpressionContext,
|
|
352
|
+
logger: Logger = new ConsoleLogger(),
|
|
353
|
+
abortSignal?: AbortSignal,
|
|
354
|
+
dir?: string
|
|
355
|
+
): Promise<ShellResult> {
|
|
356
|
+
const args = argsTemplates.map((t) => ExpressionEvaluator.evaluateString(t, context));
|
|
357
|
+
const cwd = dir ? ExpressionEvaluator.evaluateString(dir, context) : undefined;
|
|
358
|
+
const env: Record<string, string> = context.env ? { ...context.env } : {};
|
|
359
|
+
const hostEnv = filterSensitiveEnv(Bun.env);
|
|
360
|
+
const mergedEnv = { ...hostEnv, ...env };
|
|
361
|
+
const maxOutputBytes = LIMITS.MAX_PROCESS_OUTPUT_BYTES;
|
|
362
|
+
|
|
363
|
+
const proc = Bun.spawn(args, {
|
|
364
|
+
cwd,
|
|
365
|
+
env: mergedEnv,
|
|
366
|
+
stdout: 'pipe',
|
|
367
|
+
stderr: 'pipe',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const abortHandler = () => {
|
|
371
|
+
try {
|
|
372
|
+
proc.kill();
|
|
373
|
+
} catch {}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (abortSignal) {
|
|
377
|
+
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const stdoutPromise = readStreamWithLimit(proc.stdout, maxOutputBytes);
|
|
382
|
+
const stderrPromise = readStreamWithLimit(proc.stderr, maxOutputBytes);
|
|
383
|
+
|
|
384
|
+
const exitCode = await proc.exited;
|
|
385
|
+
const [stdoutResult, stderrResult] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
386
|
+
|
|
387
|
+
if (abortSignal) {
|
|
388
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
stdout: stdoutResult.text,
|
|
393
|
+
stderr: stderrResult.text,
|
|
394
|
+
exitCode,
|
|
395
|
+
stdoutTruncated: stdoutResult.truncated,
|
|
396
|
+
stderrTruncated: stderrResult.truncated,
|
|
397
|
+
};
|
|
398
|
+
} finally {
|
|
399
|
+
if (abortSignal) {
|
|
400
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { type ExpressionContext, ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
3
|
+
import type { WorkflowStep } from '../../parser/schema.ts';
|
|
4
|
+
import { WorkflowParser } from '../../parser/workflow-parser';
|
|
5
|
+
import type { Logger } from '../../utils/logger.ts';
|
|
6
|
+
import { WorkflowRegistry } from '../../utils/workflow-registry';
|
|
7
|
+
import type { MCPManager } from '../mcp-manager.ts';
|
|
8
|
+
import type { StepResult } from './types.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Interface to avoid circular dependencies with WorkflowRunner
|
|
12
|
+
*/
|
|
13
|
+
export interface RunnerFactory {
|
|
14
|
+
create(
|
|
15
|
+
workflow: any,
|
|
16
|
+
options: { signal?: AbortSignal; [key: string]: any }
|
|
17
|
+
): {
|
|
18
|
+
run(): Promise<Record<string, unknown>>;
|
|
19
|
+
runId: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Execute a sub-workflow step
|
|
25
|
+
*/
|
|
26
|
+
export async function executeSubWorkflow(
|
|
27
|
+
step: WorkflowStep,
|
|
28
|
+
context: ExpressionContext,
|
|
29
|
+
options: {
|
|
30
|
+
runnerFactory: RunnerFactory;
|
|
31
|
+
parentWorkflowDir?: string;
|
|
32
|
+
parentDbPath: string;
|
|
33
|
+
parentLogger: Logger;
|
|
34
|
+
parentMcpManager: MCPManager;
|
|
35
|
+
parentDepth: number;
|
|
36
|
+
parentOptions: any;
|
|
37
|
+
abortSignal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
): Promise<StepResult> {
|
|
40
|
+
if (options.abortSignal?.aborted) {
|
|
41
|
+
throw new Error('Sub-workflow aborted');
|
|
42
|
+
}
|
|
43
|
+
const workflowPath = WorkflowRegistry.resolvePath(step.path, options.parentWorkflowDir);
|
|
44
|
+
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
45
|
+
const subWorkflowDir = dirname(workflowPath);
|
|
46
|
+
|
|
47
|
+
// Evaluate inputs for the sub-workflow
|
|
48
|
+
const inputs: Record<string, unknown> = {};
|
|
49
|
+
if (step.inputs) {
|
|
50
|
+
for (const [key, value] of Object.entries(step.inputs)) {
|
|
51
|
+
inputs[key] = ExpressionEvaluator.evaluate(value, context);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create a new runner for the sub-workflow via factory to avoid circular imports
|
|
56
|
+
const subRunner = options.runnerFactory.create(workflow, {
|
|
57
|
+
...options.parentOptions,
|
|
58
|
+
inputs,
|
|
59
|
+
dbPath: options.parentDbPath,
|
|
60
|
+
logger: options.parentLogger,
|
|
61
|
+
mcpManager: options.parentMcpManager,
|
|
62
|
+
workflowDir: subWorkflowDir,
|
|
63
|
+
depth: options.parentDepth + 1,
|
|
64
|
+
signal: options.abortSignal,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const output = await subRunner.run();
|
|
69
|
+
|
|
70
|
+
const rawOutputs =
|
|
71
|
+
typeof output === 'object' && output !== null && !Array.isArray(output) ? output : {};
|
|
72
|
+
const mappedOutputs: Record<string, unknown> = {};
|
|
73
|
+
|
|
74
|
+
// Handle explicit output mapping
|
|
75
|
+
if (step.outputMapping) {
|
|
76
|
+
for (const [alias, mapping] of Object.entries(step.outputMapping)) {
|
|
77
|
+
let originalKey: string;
|
|
78
|
+
let defaultValue: unknown;
|
|
79
|
+
|
|
80
|
+
if (typeof mapping === 'string') {
|
|
81
|
+
originalKey = mapping;
|
|
82
|
+
} else {
|
|
83
|
+
originalKey = mapping.from;
|
|
84
|
+
defaultValue = mapping.default;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (originalKey in rawOutputs) {
|
|
88
|
+
mappedOutputs[alias] = rawOutputs[originalKey];
|
|
89
|
+
} else if (defaultValue !== undefined) {
|
|
90
|
+
mappedOutputs[alias] = defaultValue;
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Sub-workflow output "${originalKey}" not found (required by mapping "${alias}" in step "${step.id}")`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
output: {
|
|
101
|
+
...mappedOutputs,
|
|
102
|
+
outputs: rawOutputs, // Namespaced raw outputs
|
|
103
|
+
__subRunId: subRunner.runId, // Track sub-workflow run ID for rollback
|
|
104
|
+
},
|
|
105
|
+
status: 'success',
|
|
106
|
+
};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
output: null,
|
|
110
|
+
status: 'failed',
|
|
111
|
+
error: error instanceof Error ? error.message : String(error),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MemoryDb } from '../../db/memory-db.ts';
|
|
2
|
+
import type { WorkflowDb } from '../../db/workflow-db.ts';
|
|
3
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
4
|
+
import type { WorkflowStep } from '../../parser/schema.ts';
|
|
5
|
+
import type { Logger } from '../../utils/logger.ts';
|
|
6
|
+
import type { SafeSandbox } from '../../utils/sandbox.ts';
|
|
7
|
+
import type { WorkflowEvent } from '../events.ts';
|
|
8
|
+
import type { getAdapter } from '../llm-adapter.ts';
|
|
9
|
+
import type { MCPManager } from '../mcp-manager.ts';
|
|
10
|
+
import type { executeLlmStep } from './llm-executor.ts';
|
|
11
|
+
|
|
12
|
+
export class WorkflowSuspendedError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly message: string,
|
|
15
|
+
public readonly stepId: string,
|
|
16
|
+
public readonly inputType: 'confirm' | 'text'
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'WorkflowSuspendedError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WorkflowWaitingError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
public readonly message: string,
|
|
26
|
+
public readonly stepId: string,
|
|
27
|
+
public readonly wakeAt?: string
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'WorkflowWaitingError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StepResult {
|
|
35
|
+
output: unknown;
|
|
36
|
+
status: 'success' | 'failed' | 'suspended' | 'skipped' | 'waiting';
|
|
37
|
+
error?: string;
|
|
38
|
+
usage?: {
|
|
39
|
+
prompt_tokens: number;
|
|
40
|
+
completion_tokens: number;
|
|
41
|
+
total_tokens: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StepExecutorOptions {
|
|
46
|
+
executeWorkflowFn?: (
|
|
47
|
+
step: WorkflowStep,
|
|
48
|
+
context: ExpressionContext,
|
|
49
|
+
abortSignal?: AbortSignal
|
|
50
|
+
) => Promise<StepResult>;
|
|
51
|
+
mcpManager?: MCPManager;
|
|
52
|
+
db?: WorkflowDb;
|
|
53
|
+
memoryDb?: MemoryDb;
|
|
54
|
+
runId?: string;
|
|
55
|
+
stepExecutionId?: string;
|
|
56
|
+
artifactRoot?: string;
|
|
57
|
+
workflowDir?: string;
|
|
58
|
+
workflowName?: string;
|
|
59
|
+
redactForStorage?: (value: unknown) => unknown;
|
|
60
|
+
dryRun?: boolean;
|
|
61
|
+
abortSignal?: AbortSignal;
|
|
62
|
+
debug?: boolean;
|
|
63
|
+
allowInsecure?: boolean;
|
|
64
|
+
emitEvent?: (event: WorkflowEvent) => void;
|
|
65
|
+
getAdapter?: typeof getAdapter;
|
|
66
|
+
executeStep?: any; // To avoid circular dependency
|
|
67
|
+
executeLlmStep?: typeof executeLlmStep;
|
|
68
|
+
sandbox?: typeof SafeSandbox;
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { WorkflowDb } from '../../db/workflow-db.ts';
|
|
2
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
3
|
+
import { ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
4
|
+
import type { WaitStep } from '../../parser/schema.ts';
|
|
5
|
+
import { container } from '../../utils/container.ts';
|
|
6
|
+
import type { Logger } from '../../utils/logger.ts';
|
|
7
|
+
import type { StepExecutorOptions, StepResult } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a wait step (waiting for an external event)
|
|
11
|
+
*/
|
|
12
|
+
export async function executeWaitStep(
|
|
13
|
+
step: WaitStep,
|
|
14
|
+
context: ExpressionContext,
|
|
15
|
+
logger: Logger,
|
|
16
|
+
options: StepExecutorOptions
|
|
17
|
+
): Promise<StepResult> {
|
|
18
|
+
if (options.abortSignal?.aborted) {
|
|
19
|
+
throw new Error('Wait operation aborted');
|
|
20
|
+
}
|
|
21
|
+
const eventName = ExpressionEvaluator.evaluateString(step.event, context);
|
|
22
|
+
const db = options.db ?? container.resolveOptional<WorkflowDb>('db');
|
|
23
|
+
if (!db) {
|
|
24
|
+
throw new Error('Workflow database not initialized');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const event = await db.getEvent(eventName);
|
|
28
|
+
|
|
29
|
+
if (event) {
|
|
30
|
+
logger.log(` ✓ Event '${eventName}' occurred`);
|
|
31
|
+
|
|
32
|
+
// If oneShot is true (default), consume the event
|
|
33
|
+
if (step.oneShot !== false) {
|
|
34
|
+
await db.deleteEvent(eventName);
|
|
35
|
+
logger.log(` 🗑️ One-shot event '${eventName}' consumed`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let output = null;
|
|
39
|
+
if (event.data) {
|
|
40
|
+
try {
|
|
41
|
+
output = JSON.parse(event.data);
|
|
42
|
+
} catch {
|
|
43
|
+
output = event.data;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
status: 'success',
|
|
48
|
+
output,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Not occurred, suspend
|
|
53
|
+
logger.log(` ⏳ Waiting for event: ${eventName}`);
|
|
54
|
+
return {
|
|
55
|
+
status: 'suspended',
|
|
56
|
+
output: { event: eventName },
|
|
57
|
+
error: `Waiting for event: ${eventName}`,
|
|
58
|
+
};
|
|
59
|
+
}
|