@enactprotocol/execution 2.2.4 → 2.3.1

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,575 @@
1
+ /**
2
+ * Docker Execution Provider
3
+ *
4
+ * Executes commands inside Docker/Podman/nerdctl containers using direct CLI invocation.
5
+ * Simpler than the Dagger provider — no SDK dependency, just spawns `docker run`.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import type { Action, ActionsManifest, ToolManifest } from "@enactprotocol/shared";
13
+ import {
14
+ applyDefaults,
15
+ detectRuntime,
16
+ getEffectiveInputSchema,
17
+ prepareActionCommand,
18
+ validateInputs,
19
+ } from "@enactprotocol/shared";
20
+ import type {
21
+ ContainerRuntime,
22
+ EngineHealth,
23
+ ExecutionErrorCode,
24
+ ExecutionInput,
25
+ ExecutionMetadata,
26
+ ExecutionOptions,
27
+ ExecutionOutput,
28
+ ExecutionProvider,
29
+ ExecutionResult,
30
+ } from "@enactprotocol/shared";
31
+
32
+ /**
33
+ * Configuration for the Docker execution provider
34
+ */
35
+ export interface DockerProviderConfig {
36
+ /** Default timeout in milliseconds */
37
+ defaultTimeout?: number;
38
+ /** Enable verbose logging */
39
+ verbose?: boolean;
40
+ /** Preferred container runtime (auto-detected if not set) */
41
+ preferredRuntime?: ContainerRuntime;
42
+ }
43
+
44
+ /**
45
+ * Provider for executing commands inside Docker/Podman containers
46
+ *
47
+ * Uses direct CLI invocation (`docker run`, `podman run`, etc.)
48
+ * for simpler, dependency-free container execution.
49
+ */
50
+ export class DockerExecutionProvider implements ExecutionProvider {
51
+ readonly name = "docker";
52
+ private defaultTimeout: number;
53
+ private verbose: boolean;
54
+ private runtime: ContainerRuntime | null = null;
55
+
56
+ constructor(config: DockerProviderConfig = {}) {
57
+ this.defaultTimeout = config.defaultTimeout ?? 300000; // 5 minutes
58
+ this.verbose = config.verbose ?? false;
59
+ if (config.preferredRuntime) {
60
+ this.runtime = config.preferredRuntime;
61
+ }
62
+ }
63
+
64
+ async initialize(): Promise<void> {
65
+ if (!this.runtime) {
66
+ const detection = detectRuntime();
67
+ if (detection.found && detection.runtime) {
68
+ this.runtime = detection.runtime;
69
+ }
70
+ }
71
+ }
72
+
73
+ async isAvailable(): Promise<boolean> {
74
+ const detection = detectRuntime();
75
+ return detection.found;
76
+ }
77
+
78
+ async getHealth(): Promise<EngineHealth> {
79
+ const detection = detectRuntime();
80
+ return {
81
+ healthy: detection.found,
82
+ runtime: this.runtime ?? "docker",
83
+ consecutiveFailures: 0,
84
+ };
85
+ }
86
+
87
+ async shutdown(): Promise<void> {
88
+ // Nothing to clean up
89
+ }
90
+
91
+ /**
92
+ * Execute a tool using its manifest.command
93
+ */
94
+ async execute(
95
+ manifest: ToolManifest,
96
+ input: ExecutionInput,
97
+ options: ExecutionOptions = {}
98
+ ): Promise<ExecutionResult> {
99
+ const startTime = new Date();
100
+ const executionId = this.generateExecutionId();
101
+
102
+ if (!manifest.command) {
103
+ return this.createErrorResult(
104
+ manifest.name,
105
+ executionId,
106
+ startTime,
107
+ "COMMAND_ERROR",
108
+ "No command specified in manifest"
109
+ );
110
+ }
111
+
112
+ if (!this.runtime) {
113
+ return this.createErrorResult(
114
+ manifest.name,
115
+ executionId,
116
+ startTime,
117
+ "CONTAINER_ERROR",
118
+ "No container runtime available"
119
+ );
120
+ }
121
+
122
+ const sourceDir = this.resolveSourceDir(options);
123
+ const timeoutMs = this.parseTimeout(options.timeout ?? manifest.timeout);
124
+ const image = await this.resolveImage(manifest, sourceDir);
125
+
126
+ // Merge params as env vars so ${param} shell substitution works in the container
127
+ const env: Record<string, string> = { ...input.envOverrides };
128
+ for (const [key, value] of Object.entries(input.params)) {
129
+ if (value !== undefined && value !== null) {
130
+ env[key] = String(value);
131
+ }
132
+ }
133
+
134
+ try {
135
+ // Run build commands if specified (e.g., manifest.build or hooks.build)
136
+ if (manifest.build) {
137
+ const buildCommands = Array.isArray(manifest.build) ? manifest.build : [manifest.build];
138
+ for (const cmd of buildCommands) {
139
+ const buildArgs = this.buildRunArgs(image, sourceDir, env, cmd);
140
+ const buildOutput = await this.runContainer(buildArgs, 600000); // 10 min build timeout
141
+ if (buildOutput.exitCode !== 0) {
142
+ return this.createErrorResult(
143
+ manifest.name,
144
+ executionId,
145
+ startTime,
146
+ "BUILD_ERROR",
147
+ `Build failed (exit ${buildOutput.exitCode}): ${buildOutput.stderr}`
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ const dockerArgs = this.buildRunArgs(image, sourceDir, env, manifest.command);
154
+ const output = await this.runContainer(dockerArgs, timeoutMs);
155
+
156
+ const endTime = new Date();
157
+ const metadata: ExecutionMetadata = {
158
+ toolName: manifest.name,
159
+ containerImage: image,
160
+ startTime,
161
+ endTime,
162
+ durationMs: endTime.getTime() - startTime.getTime(),
163
+ cached: false,
164
+ executionId,
165
+ };
166
+
167
+ if (output.exitCode !== 0) {
168
+ return {
169
+ success: false,
170
+ output,
171
+ metadata,
172
+ error: {
173
+ code: "COMMAND_ERROR",
174
+ message: `Command exited with code ${output.exitCode}`,
175
+ },
176
+ };
177
+ }
178
+
179
+ return { success: true, output, metadata };
180
+ } catch (error) {
181
+ const message = error instanceof Error ? error.message : String(error);
182
+ const code: ExecutionErrorCode = message === "TIMEOUT" ? "TIMEOUT" : "CONTAINER_ERROR";
183
+ return this.createErrorResult(manifest.name, executionId, startTime, code, message);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Execute a raw command in a container
189
+ */
190
+ async exec(
191
+ manifest: ToolManifest,
192
+ command: string,
193
+ options: ExecutionOptions = {}
194
+ ): Promise<ExecutionResult> {
195
+ return this.execute({ ...manifest, command }, { params: {} }, options);
196
+ }
197
+
198
+ /**
199
+ * Execute an action from ACTIONS.yaml
200
+ */
201
+ async executeAction(
202
+ manifest: ToolManifest,
203
+ actionsManifest: ActionsManifest,
204
+ actionName: string,
205
+ action: Action,
206
+ input: ExecutionInput,
207
+ options: ExecutionOptions = {}
208
+ ): Promise<ExecutionResult> {
209
+ const startTime = new Date();
210
+ const executionId = this.generateExecutionId();
211
+ const toolLabel = `${manifest.name}:${actionName}`;
212
+
213
+ if (!this.runtime) {
214
+ return this.createErrorResult(
215
+ toolLabel,
216
+ executionId,
217
+ startTime,
218
+ "CONTAINER_ERROR",
219
+ "No container runtime available"
220
+ );
221
+ }
222
+
223
+ // Validate inputs
224
+ const effectiveSchema = getEffectiveInputSchema(action);
225
+ const validation = validateInputs(input.params, effectiveSchema);
226
+ if (!validation.valid) {
227
+ const errorMessages = validation.errors.map((e) => `${e.path}: ${e.message}`);
228
+ return this.createErrorResult(
229
+ toolLabel,
230
+ executionId,
231
+ startTime,
232
+ "VALIDATION_ERROR",
233
+ `Input validation failed: ${errorMessages.join(", ")}`
234
+ );
235
+ }
236
+
237
+ const params = applyDefaults(input.params, effectiveSchema);
238
+
239
+ // Prepare command
240
+ let commandArray: string[];
241
+ try {
242
+ if (typeof action.command === "string") {
243
+ commandArray = action.command.split(/\s+/).filter((s) => s.length > 0);
244
+ } else {
245
+ commandArray = prepareActionCommand(action.command, params, effectiveSchema);
246
+ }
247
+ } catch (error) {
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ return this.createErrorResult(
250
+ toolLabel,
251
+ executionId,
252
+ startTime,
253
+ "COMMAND_ERROR",
254
+ `Failed to prepare action command: ${message}`
255
+ );
256
+ }
257
+
258
+ if (commandArray.length === 0) {
259
+ return this.createErrorResult(
260
+ toolLabel,
261
+ executionId,
262
+ startTime,
263
+ "COMMAND_ERROR",
264
+ "Action command is empty"
265
+ );
266
+ }
267
+
268
+ const sourceDir = this.resolveSourceDir(options);
269
+ const timeoutMs = this.parseTimeout(options.timeout ?? manifest.timeout);
270
+ const image = await this.resolveImage(manifest, sourceDir);
271
+
272
+ // Build environment
273
+ const env: Record<string, string> = { ...input.envOverrides };
274
+ if (actionsManifest.env) {
275
+ for (const [key, envVar] of Object.entries(actionsManifest.env)) {
276
+ if (envVar.default && !env[key]) {
277
+ env[key] = envVar.default;
278
+ }
279
+ }
280
+ }
281
+
282
+ try {
283
+ // Run build if needed
284
+ if (actionsManifest.build) {
285
+ const buildCommands = Array.isArray(actionsManifest.build)
286
+ ? actionsManifest.build
287
+ : [actionsManifest.build];
288
+ for (const cmd of buildCommands) {
289
+ const buildArgs = this.buildRunArgs(image, sourceDir, env, cmd);
290
+ const buildOutput = await this.runContainer(buildArgs, 600000); // 10 min build timeout
291
+ if (buildOutput.exitCode !== 0) {
292
+ return this.createErrorResult(
293
+ toolLabel,
294
+ executionId,
295
+ startTime,
296
+ "BUILD_ERROR",
297
+ `Build failed (exit ${buildOutput.exitCode}): ${buildOutput.stderr}`
298
+ );
299
+ }
300
+ }
301
+ }
302
+
303
+ // Execute action
304
+ const dockerArgs = this.buildExecArgs(image, sourceDir, env, commandArray);
305
+ const output = await this.runContainer(dockerArgs, timeoutMs);
306
+
307
+ const endTime = new Date();
308
+ const metadata: ExecutionMetadata = {
309
+ toolName: toolLabel,
310
+ containerImage: image,
311
+ startTime,
312
+ endTime,
313
+ durationMs: endTime.getTime() - startTime.getTime(),
314
+ cached: false,
315
+ executionId,
316
+ };
317
+
318
+ if (manifest.version) {
319
+ metadata.toolVersion = manifest.version;
320
+ }
321
+
322
+ if (output.exitCode !== 0) {
323
+ return {
324
+ success: false,
325
+ output,
326
+ metadata,
327
+ error: {
328
+ code: "COMMAND_ERROR",
329
+ message: `Action "${actionName}" exited with code ${output.exitCode}`,
330
+ ...(output.stderr && { details: { stderr: output.stderr } }),
331
+ },
332
+ };
333
+ }
334
+
335
+ // Try to parse JSON output
336
+ if (action.outputSchema && output.stdout) {
337
+ try {
338
+ output.parsed = JSON.parse(output.stdout);
339
+ } catch {
340
+ // Not JSON — leave as string
341
+ }
342
+ }
343
+
344
+ return { success: true, output, metadata };
345
+ } catch (error) {
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ let code: ExecutionErrorCode = "CONTAINER_ERROR";
348
+ if (message.includes("build")) code = "BUILD_ERROR";
349
+ else if (message === "TIMEOUT") code = "TIMEOUT";
350
+ return this.createErrorResult(toolLabel, executionId, startTime, code, message);
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Private helpers
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Resolve the source directory from execution options.
360
+ * Checks mountDirs for a /workspace mount first, then falls back to workdir or cwd.
361
+ */
362
+ private resolveSourceDir(options: ExecutionOptions): string {
363
+ if (options.mountDirs) {
364
+ for (const [hostPath, containerPath] of Object.entries(options.mountDirs)) {
365
+ if (containerPath === "/workspace") return hostPath;
366
+ }
367
+ }
368
+ return options.workdir ?? process.cwd();
369
+ }
370
+
371
+ private generateExecutionId(): string {
372
+ return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
373
+ }
374
+
375
+ private parseTimeout(timeout?: string): number {
376
+ if (!timeout) return this.defaultTimeout;
377
+ const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
378
+ if (!match) return this.defaultTimeout;
379
+ const value = Number.parseInt(match[1] ?? "300000", 10);
380
+ switch (match[2] ?? "ms") {
381
+ case "ms":
382
+ return value;
383
+ case "s":
384
+ return value * 1000;
385
+ case "m":
386
+ return value * 60000;
387
+ case "h":
388
+ return value * 3600000;
389
+ default:
390
+ return value;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Resolve the container image to use.
396
+ * If the skill directory has a Containerfile/Dockerfile, build it.
397
+ * Otherwise use manifest.from or a default image.
398
+ */
399
+ private async resolveImage(manifest: ToolManifest, sourceDir: string): Promise<string> {
400
+ const containerfile = existsSync(join(sourceDir, "Containerfile"))
401
+ ? "Containerfile"
402
+ : existsSync(join(sourceDir, "Dockerfile"))
403
+ ? "Dockerfile"
404
+ : null;
405
+
406
+ if (containerfile) {
407
+ const hash = createHash("sha256")
408
+ .update(`${manifest.name}:${sourceDir}`)
409
+ .digest("hex")
410
+ .slice(0, 12);
411
+ const tag = `enact-${hash}`;
412
+
413
+ if (this.verbose) {
414
+ console.error(`[docker] Building image ${tag} from ${containerfile}`);
415
+ }
416
+
417
+ await this.buildImage(sourceDir, containerfile, tag);
418
+ return tag;
419
+ }
420
+
421
+ // Use manifest.from if available, otherwise default
422
+ return ((manifest as unknown as Record<string, unknown>).from as string) ?? "alpine:latest";
423
+ }
424
+
425
+ /**
426
+ * Build a container image from a Containerfile/Dockerfile
427
+ */
428
+ private async buildImage(contextDir: string, file: string, tag: string): Promise<void> {
429
+ const runtime = this.runtime!;
430
+ const args = ["build", "-t", tag, "-f", join(contextDir, file), contextDir];
431
+
432
+ const output = await this.spawnProcess(runtime, args, 600000); // 10 min build timeout
433
+
434
+ if (output.exitCode !== 0) {
435
+ throw new Error(
436
+ `BUILD_ERROR: Image build failed (exit ${output.exitCode}): ${output.stderr}`
437
+ );
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Build docker run args for a shell command string (used by execute and build)
443
+ */
444
+ private buildRunArgs(
445
+ image: string,
446
+ sourceDir: string,
447
+ env: Record<string, string>,
448
+ command: string
449
+ ): string[] {
450
+ const args = ["run", "--rm", "-w", "/workspace"];
451
+
452
+ // Mount source directory
453
+ args.push("-v", `${resolve(sourceDir)}:/workspace`);
454
+
455
+ // Environment variables
456
+ for (const [key, value] of Object.entries(env)) {
457
+ args.push("-e", `${key}=${value}`);
458
+ }
459
+
460
+ args.push(image, "sh", "-c", command);
461
+ return args;
462
+ }
463
+
464
+ /**
465
+ * Build docker run args for an array-form command (used by executeAction)
466
+ */
467
+ private buildExecArgs(
468
+ image: string,
469
+ sourceDir: string,
470
+ env: Record<string, string>,
471
+ commandArray: string[]
472
+ ): string[] {
473
+ const args = ["run", "--rm", "-w", "/workspace"];
474
+
475
+ args.push("-v", `${resolve(sourceDir)}:/workspace`);
476
+
477
+ for (const [key, value] of Object.entries(env)) {
478
+ args.push("-e", `${key}=${value}`);
479
+ }
480
+
481
+ args.push(image, ...commandArray);
482
+ return args;
483
+ }
484
+
485
+ /**
486
+ * Run a container and capture output
487
+ */
488
+ private runContainer(dockerArgs: string[], timeoutMs: number): Promise<ExecutionOutput> {
489
+ const runtime = this.runtime!;
490
+
491
+ if (this.verbose) {
492
+ console.error(`[docker] ${runtime} ${dockerArgs.join(" ")}`);
493
+ }
494
+
495
+ return this.spawnProcess(runtime, dockerArgs, timeoutMs);
496
+ }
497
+
498
+ /**
499
+ * Spawn a process and capture its output
500
+ */
501
+ private spawnProcess(
502
+ command: string,
503
+ args: string[],
504
+ timeoutMs: number
505
+ ): Promise<ExecutionOutput> {
506
+ return new Promise((resolve, reject) => {
507
+ const child = spawn(command, args, {
508
+ stdio: ["pipe", "pipe", "pipe"],
509
+ });
510
+
511
+ let stdout = "";
512
+ let stderr = "";
513
+ let timedOut = false;
514
+
515
+ const timer = setTimeout(() => {
516
+ timedOut = true;
517
+ child.kill("SIGTERM");
518
+ setTimeout(() => child.kill("SIGKILL"), 5000);
519
+ }, timeoutMs);
520
+
521
+ child.stdout?.on("data", (data: Buffer) => {
522
+ stdout += data.toString();
523
+ });
524
+
525
+ child.stderr?.on("data", (data: Buffer) => {
526
+ stderr += data.toString();
527
+ });
528
+
529
+ child.on("error", (error) => {
530
+ clearTimeout(timer);
531
+ reject(error);
532
+ });
533
+
534
+ child.on("close", (exitCode) => {
535
+ clearTimeout(timer);
536
+ if (timedOut) {
537
+ reject(new Error("TIMEOUT"));
538
+ return;
539
+ }
540
+ resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
541
+ });
542
+ });
543
+ }
544
+
545
+ private createErrorResult(
546
+ toolName: string,
547
+ executionId: string,
548
+ startTime: Date,
549
+ code: ExecutionErrorCode,
550
+ message: string
551
+ ): ExecutionResult {
552
+ const endTime = new Date();
553
+ return {
554
+ success: false,
555
+ output: { stdout: "", stderr: message, exitCode: 1 },
556
+ metadata: {
557
+ toolName,
558
+ containerImage: "docker",
559
+ startTime,
560
+ endTime,
561
+ durationMs: endTime.getTime() - startTime.getTime(),
562
+ cached: false,
563
+ executionId,
564
+ },
565
+ error: { code, message },
566
+ };
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Create a Docker execution provider
572
+ */
573
+ export function createDockerProvider(config: DockerProviderConfig = {}): DockerExecutionProvider {
574
+ return new DockerExecutionProvider(config);
575
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @enactprotocol/execution
3
3
  *
4
- * Dagger-based execution engine for Enact tools.
4
+ * Pluggable execution backends for Enact tools.
5
5
  * This package contains Node.js-only code and should NOT be imported in browser environments.
6
6
  */
7
7
 
@@ -34,3 +34,34 @@ export {
34
34
  executeToolWithDagger,
35
35
  type DaggerProviderConfig,
36
36
  } from "./provider.js";
37
+
38
+ // Local execution provider (no containerization)
39
+ export {
40
+ LocalExecutionProvider,
41
+ createLocalProvider,
42
+ hasContainerfile,
43
+ selectExecutionMode,
44
+ type LocalProviderConfig,
45
+ } from "./local-provider.js";
46
+
47
+ // Docker execution provider (direct CLI, no Dagger SDK)
48
+ export {
49
+ DockerExecutionProvider,
50
+ createDockerProvider,
51
+ type DockerProviderConfig,
52
+ } from "./docker-provider.js";
53
+
54
+ // Remote execution provider (delegates to remote endpoint)
55
+ export {
56
+ RemoteExecutionProvider,
57
+ createRemoteProvider,
58
+ type RemoteProviderConfig,
59
+ } from "./remote-provider.js";
60
+
61
+ // Execution router (config-driven backend selection)
62
+ export {
63
+ ExecutionRouter,
64
+ createRouter,
65
+ type ExecutionRoutingConfig,
66
+ type ProviderSelectionOptions,
67
+ } from "./router.js";