@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.
- package/dist/docker-provider.d.ts +87 -0
- package/dist/docker-provider.d.ts.map +1 -0
- package/dist/docker-provider.js +406 -0
- package/dist/docker-provider.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/local-provider.d.ts +95 -0
- package/dist/local-provider.d.ts.map +1 -0
- package/dist/local-provider.js +369 -0
- package/dist/local-provider.js.map +1 -0
- package/dist/provider.d.ts +24 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +305 -20
- package/dist/provider.js.map +1 -1
- package/dist/remote-provider.d.ts +43 -0
- package/dist/remote-provider.d.ts.map +1 -0
- package/dist/remote-provider.js +154 -0
- package/dist/remote-provider.js.map +1 -0
- package/dist/router.d.ts +62 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +109 -0
- package/dist/router.js.map +1 -0
- package/package.json +2 -2
- package/src/docker-provider.ts +575 -0
- package/src/index.ts +32 -1
- package/src/local-provider.ts +513 -0
- package/src/provider.ts +409 -28
- package/src/remote-provider.ts +231 -0
- package/src/router.ts +143 -0
- package/tests/docker-provider.test.ts +207 -0
- package/tests/local-provider.test.ts +256 -0
- package/tests/remote-provider.test.ts +206 -0
- package/tests/router.test.ts +272 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/provider.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { basename } from "node:path";
|
|
9
9
|
import { type Client, type Container, ReturnType, connect } from "@dagger.io/dagger";
|
|
10
|
-
import type { ToolManifest } from "@enactprotocol/shared";
|
|
10
|
+
import type { Action, ActionsManifest, ToolManifest } from "@enactprotocol/shared";
|
|
11
11
|
import {
|
|
12
12
|
applyDefaults,
|
|
13
13
|
detectRuntime,
|
|
14
|
+
getEffectiveInputSchema,
|
|
14
15
|
interpolateCommand,
|
|
16
|
+
prepareActionCommand,
|
|
15
17
|
validateInputs,
|
|
16
18
|
} from "@enactprotocol/shared";
|
|
17
19
|
import type {
|
|
@@ -150,26 +152,8 @@ export class DaggerExecutionProvider implements ExecutionProvider {
|
|
|
150
152
|
);
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
const validation = validateInputs(input.params, manifest.inputSchema);
|
|
156
|
-
if (!validation.valid) {
|
|
157
|
-
const errorMessages = validation.errors.map((e) => `${e.path}: ${e.message}`);
|
|
158
|
-
return this.createErrorResult(
|
|
159
|
-
manifest.name,
|
|
160
|
-
containerImage,
|
|
161
|
-
executionId,
|
|
162
|
-
startTime,
|
|
163
|
-
"VALIDATION_ERROR",
|
|
164
|
-
`Input validation failed: ${errorMessages.join(", ")}`
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Apply defaults to inputs
|
|
170
|
-
const params = manifest.inputSchema
|
|
171
|
-
? applyDefaults(input.params, manifest.inputSchema)
|
|
172
|
-
: input.params;
|
|
155
|
+
// Pass params through (scripts handle their own validation via action inputSchema)
|
|
156
|
+
const params = input.params;
|
|
173
157
|
|
|
174
158
|
// Get the command from the manifest
|
|
175
159
|
const command = manifest.command;
|
|
@@ -184,15 +168,10 @@ export class DaggerExecutionProvider implements ExecutionProvider {
|
|
|
184
168
|
);
|
|
185
169
|
}
|
|
186
170
|
|
|
187
|
-
// Interpolate command with parameters
|
|
188
|
-
//
|
|
189
|
-
// (validation already caught truly missing required params above)
|
|
190
|
-
const knownParameters = manifest.inputSchema?.properties
|
|
191
|
-
? new Set(Object.keys(manifest.inputSchema.properties))
|
|
192
|
-
: undefined;
|
|
171
|
+
// Interpolate command with parameters
|
|
172
|
+
// Without per-script schema, all ${param} patterns are substituted
|
|
193
173
|
const interpolated = interpolateCommand(command, params, {
|
|
194
174
|
onMissing: "empty",
|
|
195
|
-
...(knownParameters ? { knownParameters } : {}),
|
|
196
175
|
});
|
|
197
176
|
|
|
198
177
|
// Parse timeout
|
|
@@ -305,6 +284,198 @@ export class DaggerExecutionProvider implements ExecutionProvider {
|
|
|
305
284
|
return this.execute(execManifest, input, options);
|
|
306
285
|
}
|
|
307
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Execute an action from ACTIONS.yaml
|
|
289
|
+
*
|
|
290
|
+
* This method uses the {{param}} template system which:
|
|
291
|
+
* - Passes commands directly to execve() (no shell interpolation)
|
|
292
|
+
* - Each template becomes a single argument regardless of content
|
|
293
|
+
* - Omits arguments for optional params without values
|
|
294
|
+
*
|
|
295
|
+
* @param manifest - The skill manifest (SKILL.md)
|
|
296
|
+
* @param actionsManifest - The actions manifest (ACTIONS.yaml)
|
|
297
|
+
* @param actionName - The name of the action to execute
|
|
298
|
+
* @param action - The specific action definition to execute
|
|
299
|
+
* @param input - Execution input with params
|
|
300
|
+
* @param options - Execution options
|
|
301
|
+
*/
|
|
302
|
+
async executeAction(
|
|
303
|
+
manifest: ToolManifest,
|
|
304
|
+
actionsManifest: ActionsManifest,
|
|
305
|
+
actionName: string,
|
|
306
|
+
action: Action,
|
|
307
|
+
input: ExecutionInput,
|
|
308
|
+
options: ExecutionOptions = {}
|
|
309
|
+
): Promise<ExecutionResult> {
|
|
310
|
+
const startTime = new Date();
|
|
311
|
+
const executionId = this.generateExecutionId();
|
|
312
|
+
|
|
313
|
+
// Get container image from manifest
|
|
314
|
+
const containerImage = manifest.from ?? "alpine:latest";
|
|
315
|
+
|
|
316
|
+
// Ensure initialized
|
|
317
|
+
if (!this.initialized) {
|
|
318
|
+
await this.initialize();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check runtime availability
|
|
322
|
+
if (!this.detectedRuntime) {
|
|
323
|
+
return this.createErrorResult(
|
|
324
|
+
manifest.name,
|
|
325
|
+
containerImage,
|
|
326
|
+
executionId,
|
|
327
|
+
startTime,
|
|
328
|
+
"RUNTIME_NOT_FOUND",
|
|
329
|
+
"No container runtime available (docker, podman, or nerdctl required)"
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Get effective inputSchema (defaults to empty if not provided)
|
|
334
|
+
const effectiveSchema = getEffectiveInputSchema(action);
|
|
335
|
+
|
|
336
|
+
// Validate inputs against action's inputSchema
|
|
337
|
+
const validation = validateInputs(input.params, effectiveSchema);
|
|
338
|
+
if (!validation.valid) {
|
|
339
|
+
const errorMessages = validation.errors.map((e) => `${e.path}: ${e.message}`);
|
|
340
|
+
return this.createErrorResult(
|
|
341
|
+
manifest.name,
|
|
342
|
+
containerImage,
|
|
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
|
+
// This returns an array suitable for execve() - no shell interpolation
|
|
355
|
+
let commandArray: string[];
|
|
356
|
+
try {
|
|
357
|
+
const actionCommand = action.command;
|
|
358
|
+
if (typeof actionCommand === "string") {
|
|
359
|
+
// String-form command (no templates allowed - validation ensures this)
|
|
360
|
+
// Split into args for execve
|
|
361
|
+
commandArray = actionCommand.split(/\s+/).filter((s) => s.length > 0);
|
|
362
|
+
} else {
|
|
363
|
+
// Array-form command with {{param}} templates
|
|
364
|
+
commandArray = prepareActionCommand(actionCommand, params, effectiveSchema);
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
368
|
+
return this.createErrorResult(
|
|
369
|
+
manifest.name,
|
|
370
|
+
containerImage,
|
|
371
|
+
executionId,
|
|
372
|
+
startTime,
|
|
373
|
+
"COMMAND_ERROR",
|
|
374
|
+
`Failed to prepare action command: ${message}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (commandArray.length === 0) {
|
|
379
|
+
return this.createErrorResult(
|
|
380
|
+
manifest.name,
|
|
381
|
+
containerImage,
|
|
382
|
+
executionId,
|
|
383
|
+
startTime,
|
|
384
|
+
"COMMAND_ERROR",
|
|
385
|
+
"Action command is empty"
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Parse timeout
|
|
390
|
+
const timeoutMs = this.parseTimeout(options.timeout ?? manifest.timeout);
|
|
391
|
+
|
|
392
|
+
// Execute with Dagger using direct exec (no shell wrapper)
|
|
393
|
+
try {
|
|
394
|
+
const output = await this.runActionContainer(
|
|
395
|
+
manifest,
|
|
396
|
+
actionsManifest,
|
|
397
|
+
containerImage,
|
|
398
|
+
commandArray,
|
|
399
|
+
input,
|
|
400
|
+
options,
|
|
401
|
+
timeoutMs
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const endTime = new Date();
|
|
405
|
+
this.consecutiveFailures = 0;
|
|
406
|
+
this.lastHealthCheck = endTime;
|
|
407
|
+
|
|
408
|
+
const metadata: ExecutionMetadata = {
|
|
409
|
+
toolName: `${manifest.name}:${actionName}`,
|
|
410
|
+
containerImage,
|
|
411
|
+
startTime,
|
|
412
|
+
endTime,
|
|
413
|
+
durationMs: endTime.getTime() - startTime.getTime(),
|
|
414
|
+
cached: false,
|
|
415
|
+
executionId,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
if (manifest.version) {
|
|
419
|
+
metadata.toolVersion = manifest.version;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (output.exitCode !== 0) {
|
|
423
|
+
return {
|
|
424
|
+
success: false,
|
|
425
|
+
output,
|
|
426
|
+
metadata,
|
|
427
|
+
error: {
|
|
428
|
+
code: "COMMAND_ERROR",
|
|
429
|
+
message: `Action "${actionName}" exited with code ${output.exitCode}`,
|
|
430
|
+
...(output.stderr && { details: { stderr: output.stderr } }),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Validate output against outputSchema if defined
|
|
436
|
+
if (action.outputSchema && output.stdout) {
|
|
437
|
+
try {
|
|
438
|
+
const parsed = JSON.parse(output.stdout);
|
|
439
|
+
output.parsed = parsed;
|
|
440
|
+
// TODO: Validate parsed output against outputSchema
|
|
441
|
+
} catch {
|
|
442
|
+
// Output is not JSON - that's OK, leave as string
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
success: true,
|
|
448
|
+
output,
|
|
449
|
+
metadata,
|
|
450
|
+
};
|
|
451
|
+
} catch (error) {
|
|
452
|
+
this.consecutiveFailures++;
|
|
453
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
454
|
+
|
|
455
|
+
let code: ExecutionErrorCode = "CONTAINER_ERROR";
|
|
456
|
+
let displayMessage = errorMessage;
|
|
457
|
+
|
|
458
|
+
if (errorMessage.startsWith("BUILD_ERROR:")) {
|
|
459
|
+
code = "BUILD_ERROR";
|
|
460
|
+
displayMessage = errorMessage.slice("BUILD_ERROR:".length).trim();
|
|
461
|
+
} else if (errorMessage.includes("timeout") || errorMessage === "TIMEOUT") {
|
|
462
|
+
code = "TIMEOUT";
|
|
463
|
+
displayMessage = `Action execution timed out: ${errorMessage}`;
|
|
464
|
+
} else {
|
|
465
|
+
displayMessage = `Action execution failed: ${errorMessage}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return this.createErrorResult(
|
|
469
|
+
manifest.name,
|
|
470
|
+
containerImage,
|
|
471
|
+
executionId,
|
|
472
|
+
startTime,
|
|
473
|
+
code,
|
|
474
|
+
displayMessage
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
308
479
|
/**
|
|
309
480
|
* Shutdown the provider
|
|
310
481
|
*/
|
|
@@ -553,6 +724,216 @@ export class DaggerExecutionProvider implements ExecutionProvider {
|
|
|
553
724
|
});
|
|
554
725
|
}
|
|
555
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Run action container with direct exec (no shell wrapper)
|
|
729
|
+
*
|
|
730
|
+
* This is the key difference from runContainer - commands are passed
|
|
731
|
+
* directly to execve() without shell interpolation, preventing injection.
|
|
732
|
+
*/
|
|
733
|
+
private async runActionContainer(
|
|
734
|
+
manifest: ToolManifest,
|
|
735
|
+
actionsManifest: ActionsManifest,
|
|
736
|
+
containerImage: string,
|
|
737
|
+
commandArray: string[],
|
|
738
|
+
input: ExecutionInput,
|
|
739
|
+
options: ExecutionOptions,
|
|
740
|
+
timeoutMs: number
|
|
741
|
+
): Promise<ExecutionOutput> {
|
|
742
|
+
return new Promise<ExecutionOutput>((resolve, reject) => {
|
|
743
|
+
connect(
|
|
744
|
+
async (client: Client) => {
|
|
745
|
+
try {
|
|
746
|
+
// Create container from image
|
|
747
|
+
let container: Container = client.container().from(containerImage);
|
|
748
|
+
|
|
749
|
+
// Force the image to be pulled before timeout
|
|
750
|
+
await container.platform();
|
|
751
|
+
|
|
752
|
+
// Set working directory
|
|
753
|
+
const workdir = options.workdir ?? this.config.workdir ?? "/workspace";
|
|
754
|
+
container = container.withWorkdir(workdir);
|
|
755
|
+
|
|
756
|
+
// Add environment variables from ACTIONS.yaml env block
|
|
757
|
+
if (actionsManifest.env) {
|
|
758
|
+
for (const [key, envVar] of Object.entries(actionsManifest.env)) {
|
|
759
|
+
if (!envVar.secret && envVar.default) {
|
|
760
|
+
container = container.withEnvVariable(key, envVar.default);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Add environment variables from manifest (SKILL.md)
|
|
766
|
+
if (manifest.env) {
|
|
767
|
+
for (const [key, envVar] of Object.entries(manifest.env)) {
|
|
768
|
+
if (!envVar.secret && envVar.default) {
|
|
769
|
+
container = container.withEnvVariable(key, envVar.default);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Add additional environment variables from options
|
|
775
|
+
if (options.additionalEnv) {
|
|
776
|
+
for (const [key, value] of Object.entries(options.additionalEnv)) {
|
|
777
|
+
container = container.withEnvVariable(key, String(value));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Add environment variable overrides from input
|
|
782
|
+
if (input.envOverrides) {
|
|
783
|
+
for (const [key, value] of Object.entries(input.envOverrides)) {
|
|
784
|
+
container = container.withEnvVariable(key, value);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Add secrets from input overrides
|
|
789
|
+
if (input.secretOverrides) {
|
|
790
|
+
for (const [key, secretUri] of Object.entries(input.secretOverrides)) {
|
|
791
|
+
const secret = client.secret(secretUri);
|
|
792
|
+
container = container.withSecretVariable(key, secret);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Mount directories
|
|
797
|
+
if (options.mountDirs) {
|
|
798
|
+
for (const [source, target] of Object.entries(options.mountDirs)) {
|
|
799
|
+
container = container.withDirectory(target, client.host().directory(source));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Mount input paths
|
|
804
|
+
if (options.inputPaths) {
|
|
805
|
+
for (const inputPath of options.inputPaths) {
|
|
806
|
+
if (inputPath.name) {
|
|
807
|
+
const target = `/inputs/${inputPath.name}`;
|
|
808
|
+
if (inputPath.type === "file") {
|
|
809
|
+
container = container.withFile(target, client.host().file(inputPath.path));
|
|
810
|
+
} else {
|
|
811
|
+
container = container.withDirectory(
|
|
812
|
+
target,
|
|
813
|
+
client.host().directory(inputPath.path)
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
} else if (inputPath.type === "file") {
|
|
817
|
+
const filename = basename(inputPath.path);
|
|
818
|
+
container = container.withFile(
|
|
819
|
+
`/input/${filename}`,
|
|
820
|
+
client.host().file(inputPath.path)
|
|
821
|
+
);
|
|
822
|
+
} else {
|
|
823
|
+
container = container.withDirectory(
|
|
824
|
+
"/input",
|
|
825
|
+
client.host().directory(inputPath.path)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Run build commands from ACTIONS.yaml (cached)
|
|
832
|
+
const buildCommands = actionsManifest.build
|
|
833
|
+
? Array.isArray(actionsManifest.build)
|
|
834
|
+
? actionsManifest.build
|
|
835
|
+
: [actionsManifest.build]
|
|
836
|
+
: manifest.build
|
|
837
|
+
? Array.isArray(manifest.build)
|
|
838
|
+
? manifest.build
|
|
839
|
+
: [manifest.build]
|
|
840
|
+
: [];
|
|
841
|
+
|
|
842
|
+
for (let i = 0; i < buildCommands.length; i++) {
|
|
843
|
+
const buildCmd = buildCommands[i] as string;
|
|
844
|
+
container = container.withExec(["sh", "-c", buildCmd], { expect: ReturnType.Any });
|
|
845
|
+
const buildContainer = await container.sync();
|
|
846
|
+
|
|
847
|
+
const buildExitCode = await buildContainer.exitCode();
|
|
848
|
+
const buildStdout = await buildContainer.stdout();
|
|
849
|
+
const buildStderr = await buildContainer.stderr();
|
|
850
|
+
|
|
851
|
+
if (buildExitCode !== 0) {
|
|
852
|
+
const stepInfo =
|
|
853
|
+
buildCommands.length > 1
|
|
854
|
+
? `Build failed at step ${i + 1} of ${buildCommands.length}`
|
|
855
|
+
: "Build failed";
|
|
856
|
+
|
|
857
|
+
const details = [
|
|
858
|
+
stepInfo,
|
|
859
|
+
`Command: ${buildCmd}`,
|
|
860
|
+
`Exit code: ${buildExitCode}`,
|
|
861
|
+
buildStderr ? `\nstderr:\n${buildStderr}` : "",
|
|
862
|
+
buildStdout ? `\nstdout:\n${buildStdout}` : "",
|
|
863
|
+
]
|
|
864
|
+
.filter(Boolean)
|
|
865
|
+
.join("\n");
|
|
866
|
+
|
|
867
|
+
throw new Error(`BUILD_ERROR: ${details}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Start timeout for action execution
|
|
872
|
+
const timeoutId = setTimeout(() => {
|
|
873
|
+
reject(new Error("TIMEOUT"));
|
|
874
|
+
}, timeoutMs);
|
|
875
|
+
|
|
876
|
+
try {
|
|
877
|
+
// Execute the action command DIRECTLY (no shell wrapper!)
|
|
878
|
+
// This is the key security difference from runContainer
|
|
879
|
+
container = container.withExec(commandArray, { expect: ReturnType.Any });
|
|
880
|
+
|
|
881
|
+
const finalContainer = await container.sync();
|
|
882
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
883
|
+
finalContainer.stdout(),
|
|
884
|
+
finalContainer.stderr(),
|
|
885
|
+
finalContainer.exitCode(),
|
|
886
|
+
]);
|
|
887
|
+
|
|
888
|
+
clearTimeout(timeoutId);
|
|
889
|
+
|
|
890
|
+
const output: ExecutionOutput = {
|
|
891
|
+
stdout,
|
|
892
|
+
stderr,
|
|
893
|
+
exitCode,
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// Export /output directory if requested
|
|
897
|
+
if (options.outputPath) {
|
|
898
|
+
try {
|
|
899
|
+
await finalContainer.directory("/output").export(options.outputPath);
|
|
900
|
+
} catch {
|
|
901
|
+
// /output may not exist
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Extract output files if requested
|
|
906
|
+
if (options.outputFiles && options.outputFiles.length > 0) {
|
|
907
|
+
const extractedFiles: Record<string, Buffer> = {};
|
|
908
|
+
for (const filePath of options.outputFiles) {
|
|
909
|
+
try {
|
|
910
|
+
const content = await finalContainer.file(filePath).contents();
|
|
911
|
+
extractedFiles[filePath] = Buffer.from(content);
|
|
912
|
+
} catch {
|
|
913
|
+
// File doesn't exist - skip
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (Object.keys(extractedFiles).length > 0) {
|
|
917
|
+
output.files = extractedFiles;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
resolve(output);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
clearTimeout(timeoutId);
|
|
924
|
+
throw error;
|
|
925
|
+
}
|
|
926
|
+
} catch (error) {
|
|
927
|
+
reject(error);
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
this.config.verbose ? { LogOutput: process.stderr } : {}
|
|
931
|
+
).catch((error) => {
|
|
932
|
+
reject(error);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
556
937
|
/**
|
|
557
938
|
* Generate a unique execution ID
|
|
558
939
|
*/
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Execution Provider
|
|
3
|
+
*
|
|
4
|
+
* Delegates tool execution to a remote endpoint.
|
|
5
|
+
* Useful when no local container runtime is available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Action, ActionsManifest, ToolManifest } from "@enactprotocol/shared";
|
|
9
|
+
import { applyDefaults, getEffectiveInputSchema, validateInputs } from "@enactprotocol/shared";
|
|
10
|
+
import type {
|
|
11
|
+
EngineHealth,
|
|
12
|
+
ExecutionErrorCode,
|
|
13
|
+
ExecutionInput,
|
|
14
|
+
ExecutionOptions,
|
|
15
|
+
ExecutionProvider,
|
|
16
|
+
ExecutionResult,
|
|
17
|
+
} from "@enactprotocol/shared";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Configuration for the remote execution provider
|
|
21
|
+
*/
|
|
22
|
+
export interface RemoteProviderConfig {
|
|
23
|
+
/** Remote execution endpoint (e.g., "https://run.enact.tools" or "http://localhost:3000") */
|
|
24
|
+
endpoint: string;
|
|
25
|
+
/** Authentication token */
|
|
26
|
+
authToken?: string;
|
|
27
|
+
/** Default timeout in milliseconds */
|
|
28
|
+
defaultTimeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provider that delegates execution to a remote server.
|
|
33
|
+
*/
|
|
34
|
+
export class RemoteExecutionProvider implements ExecutionProvider {
|
|
35
|
+
readonly name = "remote";
|
|
36
|
+
private endpoint: string;
|
|
37
|
+
private authToken: string | undefined;
|
|
38
|
+
private defaultTimeout: number;
|
|
39
|
+
|
|
40
|
+
constructor(config: RemoteProviderConfig) {
|
|
41
|
+
this.endpoint = config.endpoint.replace(/\/$/, "");
|
|
42
|
+
this.authToken = config.authToken;
|
|
43
|
+
this.defaultTimeout = config.defaultTimeout ?? 300000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async initialize(): Promise<void> {
|
|
47
|
+
// No initialization needed
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async isAvailable(): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
54
|
+
const res = await fetch(`${this.endpoint}/health`, {
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
});
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
return res.ok;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getHealth(): Promise<EngineHealth> {
|
|
65
|
+
const available = await this.isAvailable();
|
|
66
|
+
return {
|
|
67
|
+
healthy: available,
|
|
68
|
+
runtime: "docker",
|
|
69
|
+
consecutiveFailures: available ? 0 : 1,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async shutdown(): Promise<void> {
|
|
74
|
+
// Nothing to clean up
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async execute(
|
|
78
|
+
manifest: ToolManifest,
|
|
79
|
+
input: ExecutionInput,
|
|
80
|
+
options: ExecutionOptions = {}
|
|
81
|
+
): Promise<ExecutionResult> {
|
|
82
|
+
return this.remoteExecute({
|
|
83
|
+
type: "execute",
|
|
84
|
+
manifest,
|
|
85
|
+
input,
|
|
86
|
+
options,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async exec(
|
|
91
|
+
manifest: ToolManifest,
|
|
92
|
+
command: string,
|
|
93
|
+
options: ExecutionOptions = {}
|
|
94
|
+
): Promise<ExecutionResult> {
|
|
95
|
+
return this.remoteExecute({
|
|
96
|
+
type: "exec",
|
|
97
|
+
manifest,
|
|
98
|
+
command,
|
|
99
|
+
input: { params: {} },
|
|
100
|
+
options,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async executeAction(
|
|
105
|
+
manifest: ToolManifest,
|
|
106
|
+
actionsManifest: ActionsManifest,
|
|
107
|
+
actionName: string,
|
|
108
|
+
action: Action,
|
|
109
|
+
input: ExecutionInput,
|
|
110
|
+
options: ExecutionOptions = {}
|
|
111
|
+
): Promise<ExecutionResult> {
|
|
112
|
+
const startTime = new Date();
|
|
113
|
+
const executionId = `remote-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
114
|
+
|
|
115
|
+
// Validate inputs locally before sending to remote
|
|
116
|
+
const effectiveSchema = getEffectiveInputSchema(action);
|
|
117
|
+
const validation = validateInputs(input.params, effectiveSchema);
|
|
118
|
+
if (!validation.valid) {
|
|
119
|
+
const errorMessages = validation.errors.map((e) => `${e.path}: ${e.message}`);
|
|
120
|
+
return this.createErrorResult(
|
|
121
|
+
`${manifest.name}:${actionName}`,
|
|
122
|
+
executionId,
|
|
123
|
+
startTime,
|
|
124
|
+
"VALIDATION_ERROR",
|
|
125
|
+
`Input validation failed: ${errorMessages.join(", ")}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const params = applyDefaults(input.params, effectiveSchema);
|
|
130
|
+
|
|
131
|
+
return this.remoteExecute({
|
|
132
|
+
type: "executeAction",
|
|
133
|
+
manifest,
|
|
134
|
+
actionsManifest,
|
|
135
|
+
actionName,
|
|
136
|
+
action,
|
|
137
|
+
input: { ...input, params },
|
|
138
|
+
options,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Private helpers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
private async remoteExecute(payload: Record<string, unknown>): Promise<ExecutionResult> {
|
|
147
|
+
const startTime = new Date();
|
|
148
|
+
const executionId = `remote-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
149
|
+
const toolName = (payload.manifest as ToolManifest)?.name ?? "unknown";
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const headers: Record<string, string> = {
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
};
|
|
155
|
+
if (this.authToken) {
|
|
156
|
+
headers.Authorization = `Bearer ${this.authToken}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timeout = setTimeout(() => controller.abort(), this.defaultTimeout);
|
|
161
|
+
|
|
162
|
+
const response = await fetch(`${this.endpoint}/execute`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers,
|
|
165
|
+
body: JSON.stringify(payload),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
const errorBody = await response.text();
|
|
173
|
+
return this.createErrorResult(
|
|
174
|
+
toolName,
|
|
175
|
+
executionId,
|
|
176
|
+
startTime,
|
|
177
|
+
"COMMAND_ERROR",
|
|
178
|
+
`Remote execution failed (${response.status}): ${errorBody}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = (await response.json()) as ExecutionResult;
|
|
183
|
+
|
|
184
|
+
// Ensure metadata has correct timing from our perspective
|
|
185
|
+
const endTime = new Date();
|
|
186
|
+
if (result.metadata) {
|
|
187
|
+
result.metadata.startTime = startTime;
|
|
188
|
+
result.metadata.endTime = endTime;
|
|
189
|
+
result.metadata.durationMs = endTime.getTime() - startTime.getTime();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return result;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
const code: ExecutionErrorCode = message.includes("abort") ? "TIMEOUT" : "COMMAND_ERROR";
|
|
196
|
+
|
|
197
|
+
return this.createErrorResult(toolName, executionId, startTime, code, message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private createErrorResult(
|
|
202
|
+
toolName: string,
|
|
203
|
+
executionId: string,
|
|
204
|
+
startTime: Date,
|
|
205
|
+
code: ExecutionErrorCode,
|
|
206
|
+
message: string
|
|
207
|
+
): ExecutionResult {
|
|
208
|
+
const endTime = new Date();
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
output: { stdout: "", stderr: message, exitCode: 1 },
|
|
212
|
+
metadata: {
|
|
213
|
+
toolName,
|
|
214
|
+
containerImage: "remote",
|
|
215
|
+
startTime,
|
|
216
|
+
endTime,
|
|
217
|
+
durationMs: endTime.getTime() - startTime.getTime(),
|
|
218
|
+
cached: false,
|
|
219
|
+
executionId,
|
|
220
|
+
},
|
|
221
|
+
error: { code, message },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a remote execution provider
|
|
228
|
+
*/
|
|
229
|
+
export function createRemoteProvider(config: RemoteProviderConfig): RemoteExecutionProvider {
|
|
230
|
+
return new RemoteExecutionProvider(config);
|
|
231
|
+
}
|