@enactprotocol/execution 2.2.4 → 2.3.4

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.
@@ -0,0 +1,513 @@
1
+ /**
2
+ * Local Execution Provider
3
+ *
4
+ * Executes commands directly on the host system without containerization.
5
+ * This is faster but provides no isolation or sandboxing.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { Action, ActionsManifest, ToolManifest } from "@enactprotocol/shared";
12
+ import {
13
+ applyDefaults,
14
+ getEffectiveInputSchema,
15
+ prepareActionCommand,
16
+ prepareCommand,
17
+ validateInputs,
18
+ } from "@enactprotocol/shared";
19
+ import type {
20
+ EngineHealth,
21
+ ExecutionErrorCode,
22
+ ExecutionInput,
23
+ ExecutionMetadata,
24
+ ExecutionOptions,
25
+ ExecutionOutput,
26
+ ExecutionProvider,
27
+ ExecutionResult,
28
+ } from "@enactprotocol/shared";
29
+
30
+ /**
31
+ * Configuration for the local execution provider
32
+ */
33
+ export interface LocalProviderConfig {
34
+ /** Default timeout in milliseconds */
35
+ defaultTimeout?: number;
36
+ /** Enable verbose logging */
37
+ verbose?: boolean;
38
+ /** Working directory for execution (defaults to skill directory) */
39
+ workdir?: string;
40
+ }
41
+
42
+ /**
43
+ * Provider for executing commands directly on the host system
44
+ *
45
+ * WARNING: This provider offers no sandboxing or isolation.
46
+ * Commands have full access to the filesystem and network.
47
+ */
48
+ export class LocalExecutionProvider implements ExecutionProvider {
49
+ readonly name = "local";
50
+ private defaultTimeout: number;
51
+ private workdir: string | undefined;
52
+
53
+ constructor(config: LocalProviderConfig = {}) {
54
+ this.defaultTimeout = config.defaultTimeout ?? 300000; // 5 minutes
55
+ this.workdir = config.workdir;
56
+ }
57
+
58
+ /**
59
+ * Initialize the provider
60
+ */
61
+ async initialize(): Promise<void> {
62
+ // No initialization needed for local execution
63
+ }
64
+
65
+ /**
66
+ * Check if the provider is available (always true for local)
67
+ */
68
+ async isAvailable(): Promise<boolean> {
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Get provider health status
74
+ */
75
+ async getHealth(): Promise<EngineHealth> {
76
+ return {
77
+ healthy: true,
78
+ runtime: "docker", // N/A but required by interface
79
+ consecutiveFailures: 0,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Generate a unique execution ID
85
+ */
86
+ private generateExecutionId(): string {
87
+ return `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
88
+ }
89
+
90
+ /**
91
+ * Parse a timeout string to milliseconds
92
+ */
93
+ private parseTimeout(timeout?: string): number {
94
+ if (!timeout) {
95
+ return this.defaultTimeout ?? 300000;
96
+ }
97
+
98
+ const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
99
+ if (!match) {
100
+ return this.defaultTimeout ?? 300000;
101
+ }
102
+
103
+ const value = Number.parseInt(match[1] ?? "300000", 10);
104
+ const unit = match[2] ?? "ms";
105
+
106
+ switch (unit) {
107
+ case "ms":
108
+ return value;
109
+ case "s":
110
+ return value * 1000;
111
+ case "m":
112
+ return value * 60000;
113
+ case "h":
114
+ return value * 3600000;
115
+ default:
116
+ return value;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Create an error result
122
+ */
123
+ private createErrorResult(
124
+ toolName: string,
125
+ executionId: string,
126
+ startTime: Date,
127
+ code: ExecutionErrorCode,
128
+ message: string
129
+ ): ExecutionResult {
130
+ const endTime = new Date();
131
+ return {
132
+ success: false,
133
+ output: {
134
+ stdout: "",
135
+ stderr: message,
136
+ exitCode: 1,
137
+ },
138
+ metadata: {
139
+ toolName,
140
+ containerImage: "local",
141
+ startTime,
142
+ endTime,
143
+ durationMs: endTime.getTime() - startTime.getTime(),
144
+ cached: false,
145
+ executionId,
146
+ },
147
+ error: {
148
+ code,
149
+ message,
150
+ },
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Run build commands if specified
156
+ */
157
+ private async runBuild(
158
+ build: string | string[],
159
+ cwd: string,
160
+ env: Record<string, string>
161
+ ): Promise<void> {
162
+ const commands = Array.isArray(build) ? build : [build];
163
+
164
+ for (const command of commands) {
165
+ await this.runCommand(command.split(/\s+/), cwd, env, 600000); // 10 min timeout for build
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Run a command and return the output
171
+ */
172
+ private runCommand(
173
+ commandArray: string[],
174
+ cwd: string,
175
+ env: Record<string, string>,
176
+ timeoutMs: number
177
+ ): Promise<ExecutionOutput> {
178
+ return new Promise((resolve, reject) => {
179
+ const [cmd, ...args] = commandArray;
180
+ if (!cmd) {
181
+ reject(new Error("Empty command"));
182
+ return;
183
+ }
184
+
185
+ const child = spawn(cmd, args, {
186
+ cwd,
187
+ env: { ...process.env, ...env },
188
+ stdio: ["pipe", "pipe", "pipe"],
189
+ });
190
+
191
+ let stdout = "";
192
+ let stderr = "";
193
+ let timedOut = false;
194
+
195
+ const timer = setTimeout(() => {
196
+ timedOut = true;
197
+ child.kill("SIGTERM");
198
+ setTimeout(() => child.kill("SIGKILL"), 5000);
199
+ }, timeoutMs);
200
+
201
+ child.stdout?.on("data", (data: Buffer) => {
202
+ stdout += data.toString();
203
+ });
204
+
205
+ child.stderr?.on("data", (data: Buffer) => {
206
+ stderr += data.toString();
207
+ });
208
+
209
+ child.on("error", (error) => {
210
+ clearTimeout(timer);
211
+ reject(error);
212
+ });
213
+
214
+ child.on("close", (exitCode) => {
215
+ clearTimeout(timer);
216
+
217
+ if (timedOut) {
218
+ reject(new Error("TIMEOUT"));
219
+ return;
220
+ }
221
+
222
+ resolve({
223
+ stdout,
224
+ stderr,
225
+ exitCode: exitCode ?? 1,
226
+ });
227
+ });
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Execute a tool (for compatibility - uses manifest.command)
233
+ */
234
+ async execute(
235
+ manifest: ToolManifest,
236
+ input: ExecutionInput,
237
+ options: ExecutionOptions = {}
238
+ ): Promise<ExecutionResult> {
239
+ const startTime = new Date();
240
+ const executionId = this.generateExecutionId();
241
+
242
+ if (!manifest.command) {
243
+ return this.createErrorResult(
244
+ manifest.name,
245
+ executionId,
246
+ startTime,
247
+ "COMMAND_ERROR",
248
+ "No command specified in manifest"
249
+ );
250
+ }
251
+
252
+ const workdir = options.workdir ?? this.workdir ?? process.cwd();
253
+ const timeoutMs = this.parseTimeout(options.timeout ?? manifest.timeout);
254
+
255
+ // Build environment
256
+ const env: Record<string, string> = {
257
+ ...input.envOverrides,
258
+ };
259
+
260
+ try {
261
+ // Interpolate parameters and prepare command array
262
+ const commandArray = prepareCommand(manifest.command, input.params);
263
+ const output = await this.runCommand(commandArray, workdir, env, timeoutMs);
264
+
265
+ const endTime = new Date();
266
+ const metadata: ExecutionMetadata = {
267
+ toolName: manifest.name,
268
+ containerImage: "local",
269
+ startTime,
270
+ endTime,
271
+ durationMs: endTime.getTime() - startTime.getTime(),
272
+ cached: false,
273
+ executionId,
274
+ };
275
+
276
+ if (output.exitCode !== 0) {
277
+ return {
278
+ success: false,
279
+ output,
280
+ metadata,
281
+ error: {
282
+ code: "COMMAND_ERROR",
283
+ message: `Command exited with code ${output.exitCode}`,
284
+ },
285
+ };
286
+ }
287
+
288
+ return {
289
+ success: true,
290
+ output,
291
+ metadata,
292
+ };
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ let code: ExecutionErrorCode = "COMMAND_ERROR";
296
+
297
+ if (message === "TIMEOUT") {
298
+ code = "TIMEOUT";
299
+ }
300
+
301
+ return this.createErrorResult(manifest.name, executionId, startTime, code, message);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Execute a raw command
307
+ */
308
+ async exec(
309
+ manifest: ToolManifest,
310
+ command: string,
311
+ options: ExecutionOptions = {}
312
+ ): Promise<ExecutionResult> {
313
+ const execManifest: ToolManifest = {
314
+ ...manifest,
315
+ command,
316
+ };
317
+ return this.execute(execManifest, { params: {} }, options);
318
+ }
319
+
320
+ /**
321
+ * Execute an action from ACTIONS.yaml
322
+ */
323
+ async executeAction(
324
+ manifest: ToolManifest,
325
+ actionsManifest: ActionsManifest,
326
+ actionName: string,
327
+ action: Action,
328
+ input: ExecutionInput,
329
+ options: ExecutionOptions = {}
330
+ ): Promise<ExecutionResult> {
331
+ const startTime = new Date();
332
+ const executionId = this.generateExecutionId();
333
+
334
+ // Get effective inputSchema (defaults to empty if not provided)
335
+ const effectiveSchema = getEffectiveInputSchema(action);
336
+
337
+ // Validate inputs against action's inputSchema
338
+ const validation = validateInputs(input.params, effectiveSchema);
339
+ if (!validation.valid) {
340
+ const errorMessages = validation.errors.map((e) => `${e.path}: ${e.message}`);
341
+ return this.createErrorResult(
342
+ `${manifest.name}:${actionName}`,
343
+ executionId,
344
+ startTime,
345
+ "VALIDATION_ERROR",
346
+ `Input validation failed: ${errorMessages.join(", ")}`
347
+ );
348
+ }
349
+
350
+ // Apply defaults to inputs
351
+ const params = applyDefaults(input.params, effectiveSchema);
352
+
353
+ // Prepare the command using {{param}} template system
354
+ let commandArray: string[];
355
+ try {
356
+ const actionCommand = action.command;
357
+ if (typeof actionCommand === "string") {
358
+ // String-form command (no templates allowed - validation ensures this)
359
+ commandArray = actionCommand.split(/\s+/).filter((s) => s.length > 0);
360
+ } else {
361
+ // Array-form command with {{param}} templates
362
+ commandArray = prepareActionCommand(actionCommand, params, effectiveSchema);
363
+ }
364
+ } catch (error) {
365
+ const message = error instanceof Error ? error.message : String(error);
366
+ return this.createErrorResult(
367
+ `${manifest.name}:${actionName}`,
368
+ executionId,
369
+ startTime,
370
+ "COMMAND_ERROR",
371
+ `Failed to prepare action command: ${message}`
372
+ );
373
+ }
374
+
375
+ if (commandArray.length === 0) {
376
+ return this.createErrorResult(
377
+ `${manifest.name}:${actionName}`,
378
+ executionId,
379
+ startTime,
380
+ "COMMAND_ERROR",
381
+ "Action command is empty"
382
+ );
383
+ }
384
+
385
+ const workdir = options.workdir ?? this.workdir ?? process.cwd();
386
+ const timeoutMs = this.parseTimeout(options.timeout ?? manifest.timeout);
387
+
388
+ // Build environment
389
+ const env: Record<string, string> = {
390
+ ...input.envOverrides,
391
+ };
392
+
393
+ // Add env from actions manifest
394
+ if (actionsManifest.env) {
395
+ for (const [key, envVar] of Object.entries(actionsManifest.env)) {
396
+ if (envVar.default && !env[key]) {
397
+ env[key] = envVar.default;
398
+ }
399
+ }
400
+ }
401
+
402
+ try {
403
+ // Run build commands if present
404
+ if (actionsManifest.build) {
405
+ await this.runBuild(actionsManifest.build, workdir, env);
406
+ }
407
+
408
+ // Execute the action command
409
+ const output = await this.runCommand(commandArray, workdir, env, timeoutMs);
410
+
411
+ const endTime = new Date();
412
+ const metadata: ExecutionMetadata = {
413
+ toolName: `${manifest.name}:${actionName}`,
414
+ containerImage: "local",
415
+ startTime,
416
+ endTime,
417
+ durationMs: endTime.getTime() - startTime.getTime(),
418
+ cached: false,
419
+ executionId,
420
+ };
421
+
422
+ if (manifest.version) {
423
+ metadata.toolVersion = manifest.version;
424
+ }
425
+
426
+ if (output.exitCode !== 0) {
427
+ return {
428
+ success: false,
429
+ output,
430
+ metadata,
431
+ error: {
432
+ code: "COMMAND_ERROR",
433
+ message: `Action "${actionName}" exited with code ${output.exitCode}`,
434
+ ...(output.stderr && { details: { stderr: output.stderr } }),
435
+ },
436
+ };
437
+ }
438
+
439
+ // Validate output against outputSchema if defined
440
+ if (action.outputSchema && output.stdout) {
441
+ try {
442
+ const parsed = JSON.parse(output.stdout);
443
+ output.parsed = parsed;
444
+ } catch {
445
+ // Output is not JSON - that's OK, leave as string
446
+ }
447
+ }
448
+
449
+ return {
450
+ success: true,
451
+ output,
452
+ metadata,
453
+ };
454
+ } catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ let code: ExecutionErrorCode = "COMMAND_ERROR";
457
+
458
+ if (message.startsWith("BUILD_ERROR:") || message.includes("build")) {
459
+ code = "BUILD_ERROR";
460
+ } else if (message === "TIMEOUT") {
461
+ code = "TIMEOUT";
462
+ }
463
+
464
+ return this.createErrorResult(
465
+ `${manifest.name}:${actionName}`,
466
+ executionId,
467
+ startTime,
468
+ code,
469
+ message
470
+ );
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Shutdown the provider (no-op for local)
476
+ */
477
+ async shutdown(): Promise<void> {
478
+ // Nothing to clean up
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Check if a directory has a Containerfile or Dockerfile
484
+ */
485
+ export function hasContainerfile(dir: string): boolean {
486
+ return existsSync(join(dir, "Containerfile")) || existsSync(join(dir, "Dockerfile"));
487
+ }
488
+
489
+ /**
490
+ * Determine the execution mode based on skill directory and options
491
+ */
492
+ export function selectExecutionMode(
493
+ skillDir: string,
494
+ options: { local?: boolean; container?: boolean }
495
+ ): "local" | "container" {
496
+ if (options.local) {
497
+ return "local";
498
+ }
499
+
500
+ if (options.container) {
501
+ return "container";
502
+ }
503
+
504
+ // Default: container if Containerfile exists, else local
505
+ return hasContainerfile(skillDir) ? "container" : "local";
506
+ }
507
+
508
+ /**
509
+ * Create a local execution provider
510
+ */
511
+ export function createLocalProvider(config: LocalProviderConfig = {}): LocalExecutionProvider {
512
+ return new LocalExecutionProvider(config);
513
+ }