@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/src/router.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Execution Router
3
+ *
4
+ * Config-driven backend selection for tool execution.
5
+ * Selects the appropriate execution provider based on:
6
+ * - CLI flags (--local overrides everything)
7
+ * - trusted_scopes (matching scopes bypass containers)
8
+ * - default backend preference
9
+ * - fallback if default is unavailable
10
+ */
11
+
12
+ import type { ExecutionBackend } from "@enactprotocol/shared";
13
+ import type { ExecutionProvider } from "@enactprotocol/shared";
14
+
15
+ /**
16
+ * Configuration for execution routing
17
+ */
18
+ export interface ExecutionRoutingConfig {
19
+ /** Default execution backend */
20
+ default?: ExecutionBackend | undefined;
21
+ /** Fallback backend if default is unavailable */
22
+ fallback?: ExecutionBackend | undefined;
23
+ /** Package scopes that bypass container isolation (e.g., ["@my-org/*"]) */
24
+ trusted_scopes?: string[] | undefined;
25
+ }
26
+
27
+ /**
28
+ * Options passed when selecting a provider
29
+ */
30
+ export interface ProviderSelectionOptions {
31
+ /** Force local execution (--local flag) */
32
+ forceLocal?: boolean | undefined;
33
+ /** Force remote execution (--remote flag) */
34
+ forceRemote?: boolean | undefined;
35
+ }
36
+
37
+ /**
38
+ * Routes execution to the appropriate provider based on config and tool identity.
39
+ */
40
+ export class ExecutionRouter {
41
+ private providers = new Map<string, ExecutionProvider>();
42
+ private config: ExecutionRoutingConfig;
43
+
44
+ constructor(config: ExecutionRoutingConfig = {}) {
45
+ this.config = config;
46
+ }
47
+
48
+ /**
49
+ * Register an execution provider by name
50
+ */
51
+ registerProvider(name: string, provider: ExecutionProvider): void {
52
+ this.providers.set(name, provider);
53
+ }
54
+
55
+ /**
56
+ * Select the appropriate provider for a given tool
57
+ */
58
+ async selectProvider(
59
+ toolName: string,
60
+ options: ProviderSelectionOptions = {}
61
+ ): Promise<ExecutionProvider> {
62
+ // 1. CLI flag overrides
63
+ if (options.forceLocal) {
64
+ const local = this.providers.get("local");
65
+ if (local) return local;
66
+ }
67
+
68
+ if (options.forceRemote) {
69
+ const remote = this.providers.get("remote");
70
+ if (remote && (await remote.isAvailable())) return remote;
71
+ }
72
+
73
+ // 2. Trusted scopes bypass containers
74
+ if (this.isTrustedScope(toolName)) {
75
+ const local = this.providers.get("local");
76
+ if (local) return local;
77
+ }
78
+
79
+ // 3. Try the configured default backend
80
+ const defaultBackend = this.config.default ?? "container";
81
+ const defaultProvider = await this.resolveBackend(defaultBackend);
82
+ if (defaultProvider) return defaultProvider;
83
+
84
+ // 4. Try the configured fallback backend
85
+ if (this.config.fallback) {
86
+ const fallbackProvider = await this.resolveBackend(this.config.fallback);
87
+ if (fallbackProvider) return fallbackProvider;
88
+ }
89
+
90
+ // 5. Last resort: local execution
91
+ const local = this.providers.get("local");
92
+ if (local) return local;
93
+
94
+ throw new Error(
95
+ "No execution provider available. Install Docker or configure a remote backend."
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Check if a tool name matches any trusted scope pattern
101
+ */
102
+ private isTrustedScope(toolName: string): boolean {
103
+ if (!this.config.trusted_scopes?.length) return false;
104
+
105
+ for (const pattern of this.config.trusted_scopes) {
106
+ if (pattern.endsWith("/*")) {
107
+ // Wildcard: "@my-org/*" matches "@my-org/anything"
108
+ const prefix = pattern.slice(0, -2);
109
+ if (toolName.startsWith(`${prefix}/`)) return true;
110
+ } else if (pattern === toolName) {
111
+ return true;
112
+ }
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Resolve a backend name to an available provider.
120
+ * "container" is an alias that tries docker, then dagger.
121
+ */
122
+ private async resolveBackend(backend: ExecutionBackend): Promise<ExecutionProvider | null> {
123
+ if (backend === "container") {
124
+ // Try dagger first (full feature support), then docker as fallback
125
+ for (const name of ["dagger", "docker"]) {
126
+ const provider = this.providers.get(name);
127
+ if (provider && (await provider.isAvailable())) return provider;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ const provider = this.providers.get(backend);
133
+ if (provider && (await provider.isAvailable())) return provider;
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Create an execution router with the given config
140
+ */
141
+ export function createRouter(config: ExecutionRoutingConfig = {}): ExecutionRouter {
142
+ return new ExecutionRouter(config);
143
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Tests for the Docker execution provider.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { DockerExecutionProvider } from "../src/docker-provider";
7
+
8
+ describe("DockerExecutionProvider", () => {
9
+ describe("instantiation", () => {
10
+ test("creates with default config", () => {
11
+ const provider = new DockerExecutionProvider();
12
+ expect(provider.name).toBe("docker");
13
+ });
14
+
15
+ test("creates with custom config", () => {
16
+ const provider = new DockerExecutionProvider({
17
+ defaultTimeout: 60000,
18
+ verbose: true,
19
+ preferredRuntime: "podman",
20
+ });
21
+ expect(provider.name).toBe("docker");
22
+ });
23
+ });
24
+
25
+ describe("interface compliance", () => {
26
+ test("implements all ExecutionProvider methods", () => {
27
+ const provider = new DockerExecutionProvider();
28
+
29
+ expect(typeof provider.initialize).toBe("function");
30
+ expect(typeof provider.isAvailable).toBe("function");
31
+ expect(typeof provider.getHealth).toBe("function");
32
+ expect(typeof provider.execute).toBe("function");
33
+ expect(typeof provider.exec).toBe("function");
34
+ expect(typeof provider.executeAction).toBe("function");
35
+ expect(typeof provider.shutdown).toBe("function");
36
+ });
37
+
38
+ test("initialize resolves without error", async () => {
39
+ const provider = new DockerExecutionProvider();
40
+ await expect(provider.initialize()).resolves.toBeUndefined();
41
+ });
42
+
43
+ test("shutdown resolves without error", async () => {
44
+ const provider = new DockerExecutionProvider();
45
+ await expect(provider.shutdown()).resolves.toBeUndefined();
46
+ });
47
+
48
+ test("isAvailable returns a boolean", async () => {
49
+ const provider = new DockerExecutionProvider();
50
+ const result = await provider.isAvailable();
51
+ expect(typeof result).toBe("boolean");
52
+ });
53
+
54
+ test("getHealth returns valid health object", async () => {
55
+ const provider = new DockerExecutionProvider();
56
+ const health = await provider.getHealth();
57
+ expect(typeof health.healthy).toBe("boolean");
58
+ expect(typeof health.runtime).toBe("string");
59
+ expect(typeof health.consecutiveFailures).toBe("number");
60
+ });
61
+ });
62
+
63
+ describe("execute", () => {
64
+ test("returns error when no command in manifest", async () => {
65
+ const provider = new DockerExecutionProvider();
66
+ await provider.initialize();
67
+
68
+ const result = await provider.execute(
69
+ {
70
+ enact: "2.0.0",
71
+ name: "@test/tool",
72
+ description: "test",
73
+ version: "1.0.0",
74
+ },
75
+ { params: {} }
76
+ );
77
+
78
+ expect(result.success).toBe(false);
79
+ expect(result.error?.code).toBe("COMMAND_ERROR");
80
+ expect(result.error?.message).toContain("No command specified");
81
+ });
82
+
83
+ test("returns error when no runtime available", async () => {
84
+ // Use an impossible runtime to force unavailability
85
+ const provider = new DockerExecutionProvider({
86
+ preferredRuntime: "nerdctl", // may not be installed
87
+ });
88
+
89
+ // Don't initialize — runtime stays null
90
+ const result = await provider.execute(
91
+ {
92
+ enact: "2.0.0",
93
+ name: "@test/tool",
94
+ description: "test",
95
+ version: "1.0.0",
96
+ command: "echo hello",
97
+ },
98
+ { params: {} }
99
+ );
100
+
101
+ // Without initialize, runtime is null
102
+ expect(result.success).toBe(false);
103
+ expect(result.error?.code).toBe("CONTAINER_ERROR");
104
+ });
105
+ });
106
+
107
+ describe("executeAction", () => {
108
+ test("validates inputs against action schema", async () => {
109
+ const provider = new DockerExecutionProvider();
110
+ await provider.initialize();
111
+
112
+ const result = await provider.executeAction(
113
+ {
114
+ enact: "2.0.0",
115
+ name: "@test/tool",
116
+ description: "test",
117
+ version: "1.0.0",
118
+ },
119
+ {
120
+ actions: {
121
+ greet: {
122
+ description: "Greet",
123
+ command: ["echo", "hello", "{{name}}"],
124
+ inputSchema: {
125
+ type: "object" as const,
126
+ properties: {
127
+ name: { type: "string" as const },
128
+ },
129
+ required: ["name"],
130
+ },
131
+ },
132
+ },
133
+ },
134
+ "greet",
135
+ {
136
+ description: "Greet",
137
+ command: ["echo", "hello", "{{name}}"],
138
+ inputSchema: {
139
+ type: "object" as const,
140
+ properties: {
141
+ name: { type: "string" as const },
142
+ },
143
+ required: ["name"],
144
+ },
145
+ },
146
+ { params: {} } // Missing required 'name'
147
+ );
148
+
149
+ expect(result.success).toBe(false);
150
+ expect(result.error?.code).toBe("VALIDATION_ERROR");
151
+ });
152
+
153
+ test("returns error for empty command array", async () => {
154
+ const provider = new DockerExecutionProvider();
155
+ await provider.initialize();
156
+
157
+ // Skip this test if no runtime
158
+ if (!(await provider.isAvailable())) return;
159
+
160
+ const result = await provider.executeAction(
161
+ {
162
+ enact: "2.0.0",
163
+ name: "@test/tool",
164
+ description: "test",
165
+ version: "1.0.0",
166
+ },
167
+ {
168
+ actions: {
169
+ empty: {
170
+ description: "Empty",
171
+ command: "",
172
+ },
173
+ },
174
+ },
175
+ "empty",
176
+ { description: "Empty", command: "" },
177
+ { params: {} }
178
+ );
179
+
180
+ expect(result.success).toBe(false);
181
+ expect(result.error?.code).toBe("COMMAND_ERROR");
182
+ });
183
+ });
184
+
185
+ describe("metadata", () => {
186
+ test("error results include correct metadata", async () => {
187
+ const provider = new DockerExecutionProvider();
188
+
189
+ const result = await provider.execute(
190
+ {
191
+ enact: "2.0.0",
192
+ name: "@test/my-tool",
193
+ description: "test",
194
+ version: "1.0.0",
195
+ },
196
+ { params: {} }
197
+ );
198
+
199
+ expect(result.metadata).toBeDefined();
200
+ expect(result.metadata.toolName).toBe("@test/my-tool");
201
+ expect(result.metadata.startTime).toBeInstanceOf(Date);
202
+ expect(result.metadata.endTime).toBeInstanceOf(Date);
203
+ expect(typeof result.metadata.durationMs).toBe("number");
204
+ expect(result.metadata.executionId).toMatch(/^docker-/);
205
+ });
206
+ });
207
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tests for the Local execution provider.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import {
9
+ LocalExecutionProvider,
10
+ hasContainerfile,
11
+ selectExecutionMode,
12
+ } from "../src/local-provider";
13
+
14
+ const FIXTURES_DIR = join(import.meta.dir, "fixtures", "local-provider");
15
+
16
+ describe("LocalExecutionProvider", () => {
17
+ describe("instantiation", () => {
18
+ test("creates with default config", () => {
19
+ const provider = new LocalExecutionProvider();
20
+ expect(provider.name).toBe("local");
21
+ });
22
+
23
+ test("creates with custom config", () => {
24
+ const provider = new LocalExecutionProvider({
25
+ defaultTimeout: 60000,
26
+ verbose: true,
27
+ workdir: "/tmp/test",
28
+ });
29
+ expect(provider.name).toBe("local");
30
+ });
31
+ });
32
+
33
+ describe("interface compliance", () => {
34
+ test("implements all ExecutionProvider methods", () => {
35
+ const provider = new LocalExecutionProvider();
36
+ expect(typeof provider.initialize).toBe("function");
37
+ expect(typeof provider.isAvailable).toBe("function");
38
+ expect(typeof provider.getHealth).toBe("function");
39
+ expect(typeof provider.execute).toBe("function");
40
+ expect(typeof provider.exec).toBe("function");
41
+ expect(typeof provider.executeAction).toBe("function");
42
+ expect(typeof provider.shutdown).toBe("function");
43
+ });
44
+
45
+ test("initialize resolves without error", async () => {
46
+ const provider = new LocalExecutionProvider();
47
+ await expect(provider.initialize()).resolves.toBeUndefined();
48
+ });
49
+
50
+ test("shutdown resolves without error", async () => {
51
+ const provider = new LocalExecutionProvider();
52
+ await expect(provider.shutdown()).resolves.toBeUndefined();
53
+ });
54
+
55
+ test("isAvailable always returns true", async () => {
56
+ const provider = new LocalExecutionProvider();
57
+ expect(await provider.isAvailable()).toBe(true);
58
+ });
59
+
60
+ test("getHealth returns healthy", async () => {
61
+ const provider = new LocalExecutionProvider();
62
+ const health = await provider.getHealth();
63
+ expect(health.healthy).toBe(true);
64
+ expect(health.consecutiveFailures).toBe(0);
65
+ });
66
+ });
67
+
68
+ describe("execute", () => {
69
+ test("runs a simple echo command", async () => {
70
+ const provider = new LocalExecutionProvider();
71
+ const result = await provider.execute(
72
+ { name: "@test/echo", description: "test", command: "echo hello" },
73
+ { params: {} }
74
+ );
75
+ expect(result.success).toBe(true);
76
+ expect(result.output.stdout.trim()).toBe("hello");
77
+ expect(result.output.exitCode).toBe(0);
78
+ });
79
+
80
+ test("returns error when no command in manifest", async () => {
81
+ const provider = new LocalExecutionProvider();
82
+ const result = await provider.execute(
83
+ { name: "@test/no-cmd", description: "test" },
84
+ { params: {} }
85
+ );
86
+ expect(result.success).toBe(false);
87
+ expect(result.error?.code).toBe("COMMAND_ERROR");
88
+ expect(result.error?.message).toContain("No command");
89
+ });
90
+
91
+ test("reports failure for command with non-zero exit", async () => {
92
+ const provider = new LocalExecutionProvider();
93
+ const result = await provider.execute(
94
+ { name: "@test/fail", description: "test", command: "false" },
95
+ { params: {} }
96
+ );
97
+ expect(result.success).toBe(false);
98
+ expect(result.output.exitCode).not.toBe(0);
99
+ });
100
+
101
+ test("returns COMMAND_ERROR for non-existent command", async () => {
102
+ const provider = new LocalExecutionProvider();
103
+ const result = await provider.execute(
104
+ { name: "@test/bad", description: "test", command: "nonexistent_command_xyz" },
105
+ { params: {} }
106
+ );
107
+ expect(result.success).toBe(false);
108
+ expect(result.error?.code).toBe("COMMAND_ERROR");
109
+ });
110
+ });
111
+
112
+ describe("exec", () => {
113
+ test("runs a raw command string", async () => {
114
+ const provider = new LocalExecutionProvider();
115
+ const result = await provider.exec({ name: "@test/tool", description: "test" }, "echo world");
116
+ expect(result.success).toBe(true);
117
+ expect(result.output.stdout.trim()).toBe("world");
118
+ });
119
+ });
120
+
121
+ describe("executeAction", () => {
122
+ test("validates inputs against action schema", async () => {
123
+ const provider = new LocalExecutionProvider();
124
+ const result = await provider.executeAction(
125
+ { name: "@test/tool", description: "test" },
126
+ {
127
+ actions: {
128
+ greet: {
129
+ description: "Greet someone",
130
+ command: ["echo", "{{name}}"],
131
+ inputSchema: {
132
+ type: "object" as const,
133
+ properties: { name: { type: "string" as const } },
134
+ required: ["name"],
135
+ },
136
+ },
137
+ },
138
+ },
139
+ "greet",
140
+ {
141
+ description: "Greet someone",
142
+ command: ["echo", "{{name}}"],
143
+ inputSchema: {
144
+ type: "object" as const,
145
+ properties: { name: { type: "string" as const } },
146
+ required: ["name"],
147
+ },
148
+ },
149
+ { params: {} } // Missing required 'name'
150
+ );
151
+ expect(result.success).toBe(false);
152
+ expect(result.error?.code).toBe("VALIDATION_ERROR");
153
+ });
154
+
155
+ test("executes action with valid inputs", async () => {
156
+ const provider = new LocalExecutionProvider();
157
+ const result = await provider.executeAction(
158
+ { name: "@test/tool", description: "test" },
159
+ {
160
+ actions: {
161
+ greet: {
162
+ description: "Greet someone",
163
+ command: ["echo", "{{name}}"],
164
+ inputSchema: {
165
+ type: "object" as const,
166
+ properties: { name: { type: "string" as const } },
167
+ required: ["name"],
168
+ },
169
+ },
170
+ },
171
+ },
172
+ "greet",
173
+ {
174
+ description: "Greet someone",
175
+ command: ["echo", "{{name}}"],
176
+ inputSchema: {
177
+ type: "object" as const,
178
+ properties: { name: { type: "string" as const } },
179
+ required: ["name"],
180
+ },
181
+ },
182
+ { params: { name: "Alice" } }
183
+ );
184
+ expect(result.success).toBe(true);
185
+ expect(result.output.stdout.trim()).toBe("Alice");
186
+ });
187
+ });
188
+
189
+ describe("metadata", () => {
190
+ test("results include timing metadata", async () => {
191
+ const provider = new LocalExecutionProvider();
192
+ const before = new Date();
193
+ const result = await provider.execute(
194
+ { name: "@test/tool", description: "test", command: "echo timing" },
195
+ { params: {} }
196
+ );
197
+ const after = new Date();
198
+
199
+ expect(result.metadata.startTime.getTime()).toBeGreaterThanOrEqual(before.getTime());
200
+ expect(result.metadata.endTime.getTime()).toBeLessThanOrEqual(after.getTime());
201
+ expect(result.metadata.durationMs).toBeGreaterThanOrEqual(0);
202
+ expect(result.metadata.containerImage).toBe("local");
203
+ expect(result.metadata.executionId).toMatch(/^local-/);
204
+ });
205
+ });
206
+ });
207
+
208
+ describe("hasContainerfile", () => {
209
+ test("returns false for directory without container file", () => {
210
+ const dir = join(FIXTURES_DIR, "no-containerfile");
211
+ mkdirSync(dir, { recursive: true });
212
+ expect(hasContainerfile(dir)).toBe(false);
213
+ rmSync(dir, { recursive: true, force: true });
214
+ });
215
+
216
+ test("returns true for directory with Containerfile", () => {
217
+ const dir = join(FIXTURES_DIR, "with-containerfile");
218
+ mkdirSync(dir, { recursive: true });
219
+ writeFileSync(join(dir, "Containerfile"), "FROM alpine");
220
+ expect(hasContainerfile(dir)).toBe(true);
221
+ rmSync(dir, { recursive: true, force: true });
222
+ });
223
+
224
+ test("returns true for directory with Dockerfile", () => {
225
+ const dir = join(FIXTURES_DIR, "with-dockerfile");
226
+ mkdirSync(dir, { recursive: true });
227
+ writeFileSync(join(dir, "Dockerfile"), "FROM alpine");
228
+ expect(hasContainerfile(dir)).toBe(true);
229
+ rmSync(dir, { recursive: true, force: true });
230
+ });
231
+ });
232
+
233
+ describe("selectExecutionMode", () => {
234
+ test("returns local when --local flag is set", () => {
235
+ expect(selectExecutionMode("/tmp", { local: true })).toBe("local");
236
+ });
237
+
238
+ test("returns container when --container flag is set", () => {
239
+ expect(selectExecutionMode("/tmp", { container: true })).toBe("container");
240
+ });
241
+
242
+ test("returns local when no Containerfile exists", () => {
243
+ const dir = join(FIXTURES_DIR, "no-cf-mode");
244
+ mkdirSync(dir, { recursive: true });
245
+ expect(selectExecutionMode(dir, {})).toBe("local");
246
+ rmSync(dir, { recursive: true, force: true });
247
+ });
248
+
249
+ test("returns container when Containerfile exists", () => {
250
+ const dir = join(FIXTURES_DIR, "cf-mode");
251
+ mkdirSync(dir, { recursive: true });
252
+ writeFileSync(join(dir, "Containerfile"), "FROM alpine");
253
+ expect(selectExecutionMode(dir, {})).toBe("container");
254
+ rmSync(dir, { recursive: true, force: true });
255
+ });
256
+ });