openlattice-e2b 0.0.3
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/config.d.ts +10 -0
- package/dist/config.js +2 -0
- package/dist/e2b-provider.d.ts +20 -0
- package/dist/e2b-provider.js +255 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/package.json +38 -0
- package/src/config.ts +13 -0
- package/src/e2b-provider.ts +332 -0
- package/src/index.ts +2 -0
- package/tests/conformance.test.ts +22 -0
- package/tests/e2b-provider.test.ts +668 -0
- package/tests/integration.test.ts +189 -0
- package/tsconfig.json +16 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface E2BProviderConfig {
|
|
2
|
+
/** E2B API key. Default: reads E2B_API_KEY env var. */
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
/** Default template ID. Used when spec.runtime.image matches no known template. */
|
|
5
|
+
defaultTemplate?: string;
|
|
6
|
+
/** Map of image strings to E2B template IDs. */
|
|
7
|
+
templateMap?: Record<string, string>;
|
|
8
|
+
/** Default sandbox timeout in ms. Default: 300_000 (5 min). */
|
|
9
|
+
defaultTimeoutMs?: number;
|
|
10
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ComputeProvider, ComputeSpec, ExecOpts, ExecResult, ExtensionMap, HealthStatus, ProviderCapabilities, ProviderNode, ProviderNodeStatus } from "openlattice";
|
|
2
|
+
import type { E2BProviderConfig } from "./config";
|
|
3
|
+
export declare class E2BProvider implements ComputeProvider {
|
|
4
|
+
readonly name = "e2b";
|
|
5
|
+
readonly capabilities: ProviderCapabilities;
|
|
6
|
+
private readonly config;
|
|
7
|
+
constructor(config?: E2BProviderConfig);
|
|
8
|
+
provision(spec: ComputeSpec): Promise<ProviderNode>;
|
|
9
|
+
exec(externalId: string, command: string[], opts?: ExecOpts): Promise<ExecResult>;
|
|
10
|
+
destroy(externalId: string): Promise<void>;
|
|
11
|
+
inspect(externalId: string): Promise<ProviderNodeStatus>;
|
|
12
|
+
pause(externalId: string): Promise<void>;
|
|
13
|
+
resume(externalId: string): Promise<void>;
|
|
14
|
+
healthCheck(): Promise<HealthStatus>;
|
|
15
|
+
getExtension<K extends keyof ExtensionMap>(externalId: string, extension: K): ExtensionMap[K] | undefined;
|
|
16
|
+
private tailscaleUp;
|
|
17
|
+
private resolveTemplate;
|
|
18
|
+
private createFileExtension;
|
|
19
|
+
private createNetworkExtension;
|
|
20
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.E2BProvider = void 0;
|
|
4
|
+
const e2b_1 = require("e2b");
|
|
5
|
+
const MAX_LIFETIME_SECONDS = 86400; // 24h Pro tier
|
|
6
|
+
const E2B_API_DOMAIN = "api.e2b.dev";
|
|
7
|
+
class E2BProvider {
|
|
8
|
+
constructor(config = {}) {
|
|
9
|
+
this.name = "e2b";
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.capabilities = {
|
|
12
|
+
restart: false,
|
|
13
|
+
pause: true, // via REST API
|
|
14
|
+
snapshot: false,
|
|
15
|
+
gpu: false, // Firecracker lacks PCIe passthrough
|
|
16
|
+
logs: false, // per-command only
|
|
17
|
+
tailscale: true, // microVM has its own kernel with TUN support
|
|
18
|
+
coldStartMs: 150,
|
|
19
|
+
maxConcurrent: 100, // Pro tier
|
|
20
|
+
architectures: ["x86_64"],
|
|
21
|
+
persistentStorage: false,
|
|
22
|
+
constraints: {
|
|
23
|
+
maxLifetimeSeconds: MAX_LIFETIME_SECONDS,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// ── Required methods ────────────────────────────────────────────
|
|
28
|
+
async provision(spec) {
|
|
29
|
+
if (spec.gpu && spec.gpu.count > 0) {
|
|
30
|
+
throw new Error("[e2b] GPU is not supported (Firecracker limitation)");
|
|
31
|
+
}
|
|
32
|
+
if (spec.duration?.maxSeconds && spec.duration.maxSeconds > MAX_LIFETIME_SECONDS) {
|
|
33
|
+
throw new Error(`[e2b] max lifetime is ${MAX_LIFETIME_SECONDS}s (24h), ` +
|
|
34
|
+
`requested ${spec.duration.maxSeconds}s`);
|
|
35
|
+
}
|
|
36
|
+
const template = this.resolveTemplate(spec.runtime.image);
|
|
37
|
+
const timeoutMs = spec.duration?.maxSeconds != null
|
|
38
|
+
? spec.duration.maxSeconds * 1000
|
|
39
|
+
: this.config.defaultTimeoutMs ?? 300000;
|
|
40
|
+
const sandbox = await e2b_1.Sandbox.create(template, {
|
|
41
|
+
apiKey: this.config.apiKey,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
envs: spec.runtime.env,
|
|
44
|
+
metadata: spec.labels,
|
|
45
|
+
});
|
|
46
|
+
// Run initial command if specified
|
|
47
|
+
if (spec.runtime.command && spec.runtime.command.length > 0) {
|
|
48
|
+
const cmd = spec.runtime.command.join(" ");
|
|
49
|
+
await sandbox.commands.run(cmd, { background: true });
|
|
50
|
+
}
|
|
51
|
+
// Join Tailscale network if auth key provided
|
|
52
|
+
if (spec.network?.tailscaleAuthKey) {
|
|
53
|
+
await this.tailscaleUp(sandbox, spec.network.tailscaleAuthKey);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
externalId: sandbox.sandboxId,
|
|
57
|
+
endpoints: [],
|
|
58
|
+
metadata: { templateId: template },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async exec(externalId, command, opts) {
|
|
62
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, {
|
|
63
|
+
apiKey: this.config.apiKey,
|
|
64
|
+
});
|
|
65
|
+
const cmd = command.join(" ");
|
|
66
|
+
const result = await sandbox.commands.run(cmd, {
|
|
67
|
+
cwd: opts?.cwd,
|
|
68
|
+
envs: opts?.env,
|
|
69
|
+
timeoutMs: opts?.timeoutMs ?? 0,
|
|
70
|
+
onStdout: opts?.onStdout
|
|
71
|
+
? (data) => opts.onStdout(data)
|
|
72
|
+
: undefined,
|
|
73
|
+
onStderr: opts?.onStderr
|
|
74
|
+
? (data) => opts.onStderr(data)
|
|
75
|
+
: undefined,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
exitCode: result.exitCode,
|
|
79
|
+
stdout: result.stdout,
|
|
80
|
+
stderr: result.stderr,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async destroy(externalId) {
|
|
84
|
+
try {
|
|
85
|
+
await e2b_1.Sandbox.kill(externalId, {
|
|
86
|
+
apiKey: this.config.apiKey,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// If the sandbox is already gone, treat as idempotent
|
|
91
|
+
if (isNotFoundError(err))
|
|
92
|
+
return;
|
|
93
|
+
throw new Error(`[e2b] destroy failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async inspect(externalId) {
|
|
97
|
+
try {
|
|
98
|
+
const sandboxes = await e2b_1.Sandbox.list({
|
|
99
|
+
apiKey: this.config.apiKey,
|
|
100
|
+
});
|
|
101
|
+
const match = sandboxes.find((s) => s.sandboxId === externalId);
|
|
102
|
+
if (!match) {
|
|
103
|
+
return { status: "terminated" };
|
|
104
|
+
}
|
|
105
|
+
const status = match.state === "paused" ? "paused" : "running";
|
|
106
|
+
return {
|
|
107
|
+
status,
|
|
108
|
+
startedAt: match.startedAt ? new Date(match.startedAt) : undefined,
|
|
109
|
+
resources: {
|
|
110
|
+
cpuPercent: undefined,
|
|
111
|
+
memoryUsedMiB: match.memoryMB,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (isNotFoundError(err)) {
|
|
117
|
+
return { status: "terminated" };
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ── Optional: pause / resume ────────────────────────────────────
|
|
123
|
+
async pause(externalId) {
|
|
124
|
+
// The e2b SDK v1.13 doesn't expose pause() on Sandbox instances,
|
|
125
|
+
// so we call the REST API directly.
|
|
126
|
+
const apiKey = this.config.apiKey ?? process.env.E2B_API_KEY;
|
|
127
|
+
if (!apiKey) {
|
|
128
|
+
throw new Error("[e2b] API key required for pause");
|
|
129
|
+
}
|
|
130
|
+
const response = await fetch(`https://${E2B_API_DOMAIN}/sandboxes/${externalId}/pause`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
"X-API-Key": apiKey,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok && response.status !== 204) {
|
|
137
|
+
throw new Error(`[e2b] pause failed: ${response.status} ${response.statusText}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async resume(externalId) {
|
|
141
|
+
// Connecting to a paused sandbox auto-resumes it
|
|
142
|
+
await e2b_1.Sandbox.connect(externalId, {
|
|
143
|
+
apiKey: this.config.apiKey,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// ── Optional: healthCheck ───────────────────────────────────────
|
|
147
|
+
async healthCheck() {
|
|
148
|
+
const start = Date.now();
|
|
149
|
+
try {
|
|
150
|
+
await e2b_1.Sandbox.list({ apiKey: this.config.apiKey });
|
|
151
|
+
return {
|
|
152
|
+
healthy: true,
|
|
153
|
+
latencyMs: Date.now() - start,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return {
|
|
158
|
+
healthy: false,
|
|
159
|
+
message: err instanceof Error ? err.message : String(err),
|
|
160
|
+
latencyMs: Date.now() - start,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ── Optional: extensions ────────────────────────────────────────
|
|
165
|
+
getExtension(externalId, extension) {
|
|
166
|
+
if (extension === "files") {
|
|
167
|
+
return this.createFileExtension(externalId);
|
|
168
|
+
}
|
|
169
|
+
if (extension === "network") {
|
|
170
|
+
return this.createNetworkExtension(externalId);
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
// ── Private helpers ─────────────────────────────────────────────
|
|
175
|
+
async tailscaleUp(sandbox, authKey) {
|
|
176
|
+
const result = await sandbox.commands.run(`pgrep tailscaled >/dev/null 2>&1 || tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 1 && tailscale up --authkey=${authKey}`, { timeoutMs: 30000 });
|
|
177
|
+
if (result.exitCode !== 0) {
|
|
178
|
+
throw new Error(`[e2b] tailscale up failed: ${result.stderr.trim()}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
resolveTemplate(image) {
|
|
182
|
+
// 1. Check explicit template map
|
|
183
|
+
if (this.config.templateMap && image in this.config.templateMap) {
|
|
184
|
+
return this.config.templateMap[image];
|
|
185
|
+
}
|
|
186
|
+
// 2. If it looks like an E2B template ID (no / or :), use directly
|
|
187
|
+
if (!image.includes("/") && !image.includes(":")) {
|
|
188
|
+
return image;
|
|
189
|
+
}
|
|
190
|
+
// 3. Fall back to default template
|
|
191
|
+
if (this.config.defaultTemplate) {
|
|
192
|
+
return this.config.defaultTemplate;
|
|
193
|
+
}
|
|
194
|
+
throw new Error(`[e2b] cannot resolve template for image "${image}". ` +
|
|
195
|
+
"Provide a templateMap entry, a valid E2B template ID, or set defaultTemplate.");
|
|
196
|
+
}
|
|
197
|
+
createFileExtension(externalId) {
|
|
198
|
+
const apiKey = this.config.apiKey;
|
|
199
|
+
return {
|
|
200
|
+
async read(path) {
|
|
201
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
202
|
+
return await sandbox.files.read(path, { format: "text" });
|
|
203
|
+
},
|
|
204
|
+
async write(path, content) {
|
|
205
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
206
|
+
await sandbox.files.write(path, content.toString());
|
|
207
|
+
},
|
|
208
|
+
async list(dirPath) {
|
|
209
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
210
|
+
const entries = await sandbox.files.list(dirPath);
|
|
211
|
+
return entries.map((e) => ({
|
|
212
|
+
name: e.name,
|
|
213
|
+
path: e.path ?? `${dirPath}/${e.name}`,
|
|
214
|
+
type: e.type === "dir" ? "directory" : "file",
|
|
215
|
+
size: e.size,
|
|
216
|
+
}));
|
|
217
|
+
},
|
|
218
|
+
async remove(path) {
|
|
219
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
220
|
+
await sandbox.files.remove(path);
|
|
221
|
+
},
|
|
222
|
+
async mkdir(path) {
|
|
223
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
224
|
+
await sandbox.files.makeDir(path);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
createNetworkExtension(externalId) {
|
|
229
|
+
const apiKey = this.config.apiKey;
|
|
230
|
+
return {
|
|
231
|
+
async getUrl(port) {
|
|
232
|
+
const sandbox = await e2b_1.Sandbox.connect(externalId, { apiKey });
|
|
233
|
+
const host = sandbox.getHost(port);
|
|
234
|
+
return `https://${host}`;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
exports.E2BProvider = E2BProvider;
|
|
240
|
+
// ── Utility functions ───────────────────────────────────────────────
|
|
241
|
+
function isNotFoundError(err) {
|
|
242
|
+
if (typeof err !== "object" || err === null)
|
|
243
|
+
return false;
|
|
244
|
+
// E2B SDK throws NotFoundError for missing sandboxes
|
|
245
|
+
if ("statusCode" in err && err.statusCode === 404) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (err instanceof Error && err.constructor.name === "NotFoundError") {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (err instanceof Error && /not found|does not exist/i.test(err.message)) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.E2BProvider = void 0;
|
|
4
|
+
var e2b_provider_1 = require("./e2b-provider");
|
|
5
|
+
Object.defineProperty(exports, "E2BProvider", { enumerable: true, get: function () { return e2b_provider_1.E2BProvider; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openlattice-e2b",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "E2B compute provider for OpenLattice",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:integration": "E2B_API_KEY=$E2B_API_KEY vitest run tests/integration.test.ts tests/conformance.test.ts"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"compute",
|
|
20
|
+
"e2b",
|
|
21
|
+
"openlattice",
|
|
22
|
+
"provider",
|
|
23
|
+
"sandbox"
|
|
24
|
+
],
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"openlattice": "^0.0.3"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"e2b": "^1.13.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.2.2",
|
|
34
|
+
"openlattice": "^0.0.3",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^4.0.18"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface E2BProviderConfig {
|
|
2
|
+
/** E2B API key. Default: reads E2B_API_KEY env var. */
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
|
|
5
|
+
/** Default template ID. Used when spec.runtime.image matches no known template. */
|
|
6
|
+
defaultTemplate?: string;
|
|
7
|
+
|
|
8
|
+
/** Map of image strings to E2B template IDs. */
|
|
9
|
+
templateMap?: Record<string, string>;
|
|
10
|
+
|
|
11
|
+
/** Default sandbox timeout in ms. Default: 300_000 (5 min). */
|
|
12
|
+
defaultTimeoutMs?: number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Sandbox } from "e2b";
|
|
2
|
+
import type {
|
|
3
|
+
ComputeProvider,
|
|
4
|
+
ComputeSpec,
|
|
5
|
+
ExecOpts,
|
|
6
|
+
ExecResult,
|
|
7
|
+
ExtensionMap,
|
|
8
|
+
FileEntry,
|
|
9
|
+
FileExtension,
|
|
10
|
+
HealthStatus,
|
|
11
|
+
NetworkExtension,
|
|
12
|
+
ProviderCapabilities,
|
|
13
|
+
ProviderNode,
|
|
14
|
+
ProviderNodeStatus,
|
|
15
|
+
} from "openlattice";
|
|
16
|
+
import type { E2BProviderConfig } from "./config";
|
|
17
|
+
|
|
18
|
+
const MAX_LIFETIME_SECONDS = 86_400; // 24h Pro tier
|
|
19
|
+
const E2B_API_DOMAIN = "api.e2b.dev";
|
|
20
|
+
|
|
21
|
+
export class E2BProvider implements ComputeProvider {
|
|
22
|
+
readonly name = "e2b";
|
|
23
|
+
readonly capabilities: ProviderCapabilities;
|
|
24
|
+
private readonly config: E2BProviderConfig;
|
|
25
|
+
|
|
26
|
+
constructor(config: E2BProviderConfig = {}) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.capabilities = {
|
|
29
|
+
restart: false,
|
|
30
|
+
pause: true, // via REST API
|
|
31
|
+
snapshot: false,
|
|
32
|
+
gpu: false, // Firecracker lacks PCIe passthrough
|
|
33
|
+
logs: false, // per-command only
|
|
34
|
+
tailscale: true, // microVM has its own kernel with TUN support
|
|
35
|
+
coldStartMs: 150,
|
|
36
|
+
maxConcurrent: 100, // Pro tier
|
|
37
|
+
architectures: ["x86_64"],
|
|
38
|
+
persistentStorage: false,
|
|
39
|
+
constraints: {
|
|
40
|
+
maxLifetimeSeconds: MAX_LIFETIME_SECONDS,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Required methods ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async provision(spec: ComputeSpec): Promise<ProviderNode> {
|
|
48
|
+
if (spec.gpu && spec.gpu.count > 0) {
|
|
49
|
+
throw new Error("[e2b] GPU is not supported (Firecracker limitation)");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (spec.duration?.maxSeconds && spec.duration.maxSeconds > MAX_LIFETIME_SECONDS) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[e2b] max lifetime is ${MAX_LIFETIME_SECONDS}s (24h), ` +
|
|
55
|
+
`requested ${spec.duration.maxSeconds}s`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const template = this.resolveTemplate(spec.runtime.image);
|
|
60
|
+
const timeoutMs =
|
|
61
|
+
spec.duration?.maxSeconds != null
|
|
62
|
+
? spec.duration.maxSeconds * 1000
|
|
63
|
+
: this.config.defaultTimeoutMs ?? 300_000;
|
|
64
|
+
|
|
65
|
+
const sandbox = await Sandbox.create(template, {
|
|
66
|
+
apiKey: this.config.apiKey,
|
|
67
|
+
timeoutMs,
|
|
68
|
+
envs: spec.runtime.env,
|
|
69
|
+
metadata: spec.labels,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Run initial command if specified
|
|
73
|
+
if (spec.runtime.command && spec.runtime.command.length > 0) {
|
|
74
|
+
const cmd = spec.runtime.command.join(" ");
|
|
75
|
+
await sandbox.commands.run(cmd, { background: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Join Tailscale network if auth key provided
|
|
79
|
+
if (spec.network?.tailscaleAuthKey) {
|
|
80
|
+
await this.tailscaleUp(sandbox, spec.network.tailscaleAuthKey);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
externalId: sandbox.sandboxId,
|
|
85
|
+
endpoints: [],
|
|
86
|
+
metadata: { templateId: template },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async exec(
|
|
91
|
+
externalId: string,
|
|
92
|
+
command: string[],
|
|
93
|
+
opts?: ExecOpts
|
|
94
|
+
): Promise<ExecResult> {
|
|
95
|
+
const sandbox = await Sandbox.connect(externalId, {
|
|
96
|
+
apiKey: this.config.apiKey,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const cmd = command.join(" ");
|
|
100
|
+
|
|
101
|
+
const result = await sandbox.commands.run(cmd, {
|
|
102
|
+
cwd: opts?.cwd,
|
|
103
|
+
envs: opts?.env,
|
|
104
|
+
timeoutMs: opts?.timeoutMs ?? 0,
|
|
105
|
+
onStdout: opts?.onStdout
|
|
106
|
+
? (data: string) => opts.onStdout!(data)
|
|
107
|
+
: undefined,
|
|
108
|
+
onStderr: opts?.onStderr
|
|
109
|
+
? (data: string) => opts.onStderr!(data)
|
|
110
|
+
: undefined,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
exitCode: result.exitCode,
|
|
115
|
+
stdout: result.stdout,
|
|
116
|
+
stderr: result.stderr,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async destroy(externalId: string): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await Sandbox.kill(externalId, {
|
|
123
|
+
apiKey: this.config.apiKey,
|
|
124
|
+
});
|
|
125
|
+
} catch (err: unknown) {
|
|
126
|
+
// If the sandbox is already gone, treat as idempotent
|
|
127
|
+
if (isNotFoundError(err)) return;
|
|
128
|
+
throw new Error(
|
|
129
|
+
`[e2b] destroy failed: ${err instanceof Error ? err.message : String(err)}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async inspect(externalId: string): Promise<ProviderNodeStatus> {
|
|
135
|
+
try {
|
|
136
|
+
const sandboxes = await Sandbox.list({
|
|
137
|
+
apiKey: this.config.apiKey,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const match = sandboxes.find(
|
|
141
|
+
(s) => s.sandboxId === externalId
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!match) {
|
|
145
|
+
return { status: "terminated" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const status = match.state === "paused" ? "paused" : "running";
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
status,
|
|
152
|
+
startedAt: match.startedAt ? new Date(match.startedAt) : undefined,
|
|
153
|
+
resources: {
|
|
154
|
+
cpuPercent: undefined,
|
|
155
|
+
memoryUsedMiB: match.memoryMB,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
} catch (err: unknown) {
|
|
159
|
+
if (isNotFoundError(err)) {
|
|
160
|
+
return { status: "terminated" };
|
|
161
|
+
}
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Optional: pause / resume ────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async pause(externalId: string): Promise<void> {
|
|
169
|
+
// The e2b SDK v1.13 doesn't expose pause() on Sandbox instances,
|
|
170
|
+
// so we call the REST API directly.
|
|
171
|
+
const apiKey = this.config.apiKey ?? process.env.E2B_API_KEY;
|
|
172
|
+
if (!apiKey) {
|
|
173
|
+
throw new Error("[e2b] API key required for pause");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const response = await fetch(
|
|
177
|
+
`https://${E2B_API_DOMAIN}/sandboxes/${externalId}/pause`,
|
|
178
|
+
{
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: {
|
|
181
|
+
"X-API-Key": apiKey,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!response.ok && response.status !== 204) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[e2b] pause failed: ${response.status} ${response.statusText}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async resume(externalId: string): Promise<void> {
|
|
194
|
+
// Connecting to a paused sandbox auto-resumes it
|
|
195
|
+
await Sandbox.connect(externalId, {
|
|
196
|
+
apiKey: this.config.apiKey,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Optional: healthCheck ───────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
203
|
+
const start = Date.now();
|
|
204
|
+
try {
|
|
205
|
+
await Sandbox.list({ apiKey: this.config.apiKey });
|
|
206
|
+
return {
|
|
207
|
+
healthy: true,
|
|
208
|
+
latencyMs: Date.now() - start,
|
|
209
|
+
};
|
|
210
|
+
} catch (err: unknown) {
|
|
211
|
+
return {
|
|
212
|
+
healthy: false,
|
|
213
|
+
message: err instanceof Error ? err.message : String(err),
|
|
214
|
+
latencyMs: Date.now() - start,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Optional: extensions ────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
getExtension<K extends keyof ExtensionMap>(
|
|
222
|
+
externalId: string,
|
|
223
|
+
extension: K
|
|
224
|
+
): ExtensionMap[K] | undefined {
|
|
225
|
+
if (extension === "files") {
|
|
226
|
+
return this.createFileExtension(externalId) as ExtensionMap[K];
|
|
227
|
+
}
|
|
228
|
+
if (extension === "network") {
|
|
229
|
+
return this.createNetworkExtension(externalId) as ExtensionMap[K];
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Private helpers ─────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
private async tailscaleUp(
|
|
237
|
+
sandbox: InstanceType<typeof Sandbox>,
|
|
238
|
+
authKey: string
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const result = await sandbox.commands.run(
|
|
241
|
+
`pgrep tailscaled >/dev/null 2>&1 || tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 1 && tailscale up --authkey=${authKey}`,
|
|
242
|
+
{ timeoutMs: 30_000 }
|
|
243
|
+
);
|
|
244
|
+
if (result.exitCode !== 0) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[e2b] tailscale up failed: ${result.stderr.trim()}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private resolveTemplate(image: string): string {
|
|
252
|
+
// 1. Check explicit template map
|
|
253
|
+
if (this.config.templateMap && image in this.config.templateMap) {
|
|
254
|
+
return this.config.templateMap[image];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 2. If it looks like an E2B template ID (no / or :), use directly
|
|
258
|
+
if (!image.includes("/") && !image.includes(":")) {
|
|
259
|
+
return image;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 3. Fall back to default template
|
|
263
|
+
if (this.config.defaultTemplate) {
|
|
264
|
+
return this.config.defaultTemplate;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new Error(
|
|
268
|
+
`[e2b] cannot resolve template for image "${image}". ` +
|
|
269
|
+
"Provide a templateMap entry, a valid E2B template ID, or set defaultTemplate."
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private createFileExtension(externalId: string): FileExtension {
|
|
274
|
+
const apiKey = this.config.apiKey;
|
|
275
|
+
return {
|
|
276
|
+
async read(path: string): Promise<string | Buffer> {
|
|
277
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
278
|
+
return await sandbox.files.read(path, { format: "text" });
|
|
279
|
+
},
|
|
280
|
+
async write(path: string, content: string | Buffer): Promise<void> {
|
|
281
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
282
|
+
await sandbox.files.write(path, content.toString());
|
|
283
|
+
},
|
|
284
|
+
async list(dirPath: string): Promise<FileEntry[]> {
|
|
285
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
286
|
+
const entries = await sandbox.files.list(dirPath);
|
|
287
|
+
return entries.map((e) => ({
|
|
288
|
+
name: e.name,
|
|
289
|
+
path: e.path ?? `${dirPath}/${e.name}`,
|
|
290
|
+
type: e.type === "dir" ? ("directory" as const) : ("file" as const),
|
|
291
|
+
size: e.size,
|
|
292
|
+
}));
|
|
293
|
+
},
|
|
294
|
+
async remove(path: string): Promise<void> {
|
|
295
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
296
|
+
await sandbox.files.remove(path);
|
|
297
|
+
},
|
|
298
|
+
async mkdir(path: string): Promise<void> {
|
|
299
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
300
|
+
await sandbox.files.makeDir(path);
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private createNetworkExtension(externalId: string): NetworkExtension {
|
|
306
|
+
const apiKey = this.config.apiKey;
|
|
307
|
+
return {
|
|
308
|
+
async getUrl(port: number): Promise<string> {
|
|
309
|
+
const sandbox = await Sandbox.connect(externalId, { apiKey });
|
|
310
|
+
const host = sandbox.getHost(port);
|
|
311
|
+
return `https://${host}`;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Utility functions ───────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function isNotFoundError(err: unknown): boolean {
|
|
320
|
+
if (typeof err !== "object" || err === null) return false;
|
|
321
|
+
// E2B SDK throws NotFoundError for missing sandboxes
|
|
322
|
+
if ("statusCode" in err && (err as { statusCode: number }).statusCode === 404) {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
if (err instanceof Error && err.constructor.name === "NotFoundError") {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (err instanceof Error && /not found|does not exist/i.test(err.message)) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
package/src/index.ts
ADDED