@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/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
|
+
});
|