@larkup/sandbox 0.1.2 → 0.1.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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +6 -0
  3. package/dist/docker-runner.js +27 -15
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +3 -1
  6. package/dist/local-runner.d.ts +5 -0
  7. package/dist/local-runner.js +176 -0
  8. package/dist/mime.d.ts +1 -0
  9. package/dist/mime.js +16 -0
  10. package/dist/providers/browserbase.d.ts +12 -0
  11. package/dist/providers/browserbase.js +61 -0
  12. package/dist/providers/cloudflare.d.ts +5 -0
  13. package/dist/providers/cloudflare.js +55 -0
  14. package/dist/providers/daytona.d.ts +12 -0
  15. package/dist/providers/daytona.js +84 -0
  16. package/dist/providers/e2b.d.ts +9 -0
  17. package/dist/providers/e2b.js +70 -0
  18. package/dist/providers/flyio.d.ts +9 -0
  19. package/dist/providers/flyio.js +98 -0
  20. package/dist/providers/index.d.ts +13 -0
  21. package/dist/providers/index.js +26 -0
  22. package/dist/providers/modal.d.ts +8 -0
  23. package/dist/providers/modal.js +96 -0
  24. package/dist/providers/northflank.d.ts +9 -0
  25. package/dist/providers/northflank.js +122 -0
  26. package/dist/providers/vercel.d.ts +9 -0
  27. package/dist/providers/vercel.js +79 -0
  28. package/dist/providers/webcontainers.d.ts +13 -0
  29. package/dist/providers/webcontainers.js +28 -0
  30. package/dist/registry.d.ts +20 -0
  31. package/dist/registry.js +281 -0
  32. package/dist/remote-exec.d.ts +42 -0
  33. package/dist/remote-exec.js +144 -0
  34. package/dist/sandbox-manager.d.ts +5 -2
  35. package/dist/sandbox-manager.js +67 -46
  36. package/dist/types.d.ts +58 -9
  37. package/dist/types.js +1 -1
  38. package/package.json +21 -4
  39. package/src/docker-runner.ts +29 -19
  40. package/src/index.ts +11 -2
  41. package/src/local-runner.test.ts +22 -0
  42. package/src/local-runner.ts +199 -0
  43. package/src/mime.ts +17 -0
  44. package/src/providers/browserbase.test.ts +55 -0
  45. package/src/providers/browserbase.ts +88 -0
  46. package/src/providers/cloudflare.test.ts +63 -0
  47. package/src/providers/cloudflare.ts +72 -0
  48. package/src/providers/daytona.test.ts +60 -0
  49. package/src/providers/daytona.ts +144 -0
  50. package/src/providers/e2b.test.ts +100 -0
  51. package/src/providers/e2b.ts +115 -0
  52. package/src/providers/flyio.test.ts +69 -0
  53. package/src/providers/flyio.ts +148 -0
  54. package/src/providers/index.ts +42 -0
  55. package/src/providers/modal.test.ts +107 -0
  56. package/src/providers/modal.ts +167 -0
  57. package/src/providers/northflank.test.ts +110 -0
  58. package/src/providers/northflank.ts +199 -0
  59. package/src/providers/vercel.test.ts +84 -0
  60. package/src/providers/vercel.ts +143 -0
  61. package/src/providers/webcontainers.test.ts +21 -0
  62. package/src/providers/webcontainers.ts +36 -0
  63. package/src/registry.test.ts +84 -0
  64. package/src/registry.ts +308 -0
  65. package/src/remote-exec.test.ts +155 -0
  66. package/src/remote-exec.ts +190 -0
  67. package/src/sandbox-manager.test.ts +74 -0
  68. package/src/sandbox-manager.ts +71 -51
  69. package/src/types.ts +81 -23
  70. package/tsconfig.build.json +4 -0
  71. package/vitest.config.ts +9 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Browserbase adapter — https://docs.browserbase.com/welcome/introduction
3
+ *
4
+ * Browserbase provisions headless-Chrome sessions for Playwright/Puppeteer
5
+ * automation, not an arbitrary code-execution sandbox — see
6
+ * SANDBOX_PROVIDERS.browserbase.executionCaveat. `execute()` fails fast with
7
+ * that explanation instead of pretending to run Python/JS here.
8
+ * `verifyCredentials`/`healthCheck` are still fully real: they confirm the
9
+ * API key and project actually work.
10
+ */
11
+
12
+ import type {
13
+ SandboxHealthCheck,
14
+ ExecutionRequest,
15
+ ExecutionResult,
16
+ SandboxProviderAdapter,
17
+ } from '../types.js';
18
+ import { failedResult } from '../remote-exec.js';
19
+ import { SANDBOX_PROVIDERS } from '../registry.js';
20
+
21
+ interface BrowserbaseProject {
22
+ id: string;
23
+ }
24
+
25
+ interface BrowserbaseClient {
26
+ projects: { list(): Promise<BrowserbaseProject[]> };
27
+ }
28
+
29
+ interface BrowserbaseModule {
30
+ default: new (opts: { apiKey: string }) => BrowserbaseClient;
31
+ }
32
+
33
+ async function loadSdk(): Promise<BrowserbaseModule> {
34
+ return (await import('@browserbasehq/sdk')) as unknown as BrowserbaseModule;
35
+ }
36
+
37
+ function requireFields(credentials: Record<string, string>): { apiKey: string; projectId: string } {
38
+ const apiKey = credentials.apiKey?.trim();
39
+ const projectId = credentials.projectId?.trim();
40
+ if (!apiKey || !projectId)
41
+ throw new Error('Browserbase API key and project ID are both required.');
42
+ return { apiKey, projectId };
43
+ }
44
+
45
+ async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
46
+ const { apiKey, projectId } = requireFields(credentials);
47
+ const { default: Browserbase } = await loadSdk();
48
+ const client = new Browserbase({ apiKey });
49
+ const projects = await client.projects.list();
50
+ if (!projects.some((project) => project.id === projectId)) {
51
+ throw new Error('This API key cannot see the given Browserbase project ID.');
52
+ }
53
+ }
54
+
55
+ async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
56
+ const caveat = SANDBOX_PROVIDERS.browserbase.executionCaveat;
57
+ if (!credentials.apiKey?.trim() || !credentials.projectId?.trim()) {
58
+ return {
59
+ status: 'missing-credentials',
60
+ backend: 'browserbase',
61
+ error: 'Browserbase API key and project ID are required.',
62
+ };
63
+ }
64
+ try {
65
+ await verifyCredentials(credentials);
66
+ return { status: 'unsupported', backend: 'browserbase', error: caveat };
67
+ } catch (err) {
68
+ return {
69
+ status: 'error',
70
+ backend: 'browserbase',
71
+ error: err instanceof Error ? err.message : 'Browserbase health check failed.',
72
+ };
73
+ }
74
+ }
75
+
76
+ async function execute(
77
+ _request: ExecutionRequest,
78
+ _credentials: Record<string, string>,
79
+ ): Promise<ExecutionResult> {
80
+ return failedResult(SANDBOX_PROVIDERS.browserbase.executionCaveat!, Date.now());
81
+ }
82
+
83
+ export const browserbaseAdapter: SandboxProviderAdapter = {
84
+ id: 'browserbase',
85
+ verifyCredentials,
86
+ healthCheck,
87
+ execute,
88
+ };
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { cloudflareAdapter } from './cloudflare.js';
3
+
4
+ beforeEach(() => {
5
+ vi.stubGlobal('fetch', vi.fn());
6
+ });
7
+
8
+ describe('cloudflareAdapter.execute', () => {
9
+ it('always fails fast — @cloudflare/sandbox only runs inside a deployed Worker', async () => {
10
+ const result = await cloudflareAdapter.execute({ code: 'print(1)', language: 'python' }, {
11
+ apiToken: 't',
12
+ accountId: 'a',
13
+ });
14
+ expect(result.exitCode).toBe(1);
15
+ expect(result.stderr).toContain('Durable Object binding');
16
+ expect(fetch).not.toHaveBeenCalled();
17
+ });
18
+ });
19
+
20
+ describe('cloudflareAdapter.verifyCredentials', () => {
21
+ it('requires both apiToken and accountId', async () => {
22
+ await expect(cloudflareAdapter.verifyCredentials({ apiToken: 't' })).rejects.toThrow(
23
+ 'Cloudflare API token and account ID are both required.',
24
+ );
25
+ });
26
+
27
+ it('verifies the token, then confirms account access', async () => {
28
+ (fetch as any).mockResolvedValue({ ok: true });
29
+ await cloudflareAdapter.verifyCredentials({ apiToken: 't', accountId: 'a' });
30
+
31
+ expect(fetch).toHaveBeenNthCalledWith(1, 'https://api.cloudflare.com/client/v4/user/tokens/verify', {
32
+ headers: { Authorization: 'Bearer t' },
33
+ });
34
+ expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.cloudflare.com/client/v4/accounts/a', {
35
+ headers: { Authorization: 'Bearer t' },
36
+ });
37
+ });
38
+
39
+ it('fails when the token check fails, without checking the account', async () => {
40
+ (fetch as any).mockResolvedValue({ ok: false, status: 401 });
41
+ await expect(cloudflareAdapter.verifyCredentials({ apiToken: 'bad', accountId: 'a' })).rejects.toThrow(
42
+ 'rejected the API token',
43
+ );
44
+ expect(fetch).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it('fails when the token works but cannot see the account', async () => {
48
+ (fetch as any)
49
+ .mockResolvedValueOnce({ ok: true })
50
+ .mockResolvedValueOnce({ ok: false, status: 403 });
51
+ await expect(cloudflareAdapter.verifyCredentials({ apiToken: 't', accountId: 'wrong' })).rejects.toThrow(
52
+ 'cannot see the given Cloudflare account ID',
53
+ );
54
+ });
55
+ });
56
+
57
+ describe('cloudflareAdapter.healthCheck', () => {
58
+ it('reports status "unsupported" when credentials are valid', async () => {
59
+ (fetch as any).mockResolvedValue({ ok: true });
60
+ const result = await cloudflareAdapter.healthCheck({ apiToken: 't', accountId: 'a' });
61
+ expect(result.status).toBe('unsupported');
62
+ });
63
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cloudflare Sandbox adapter — https://developers.cloudflare.com/sandbox/
3
+ */
4
+
5
+ import type {
6
+ SandboxHealthCheck,
7
+ ExecutionRequest,
8
+ ExecutionResult,
9
+ SandboxProviderAdapter,
10
+ } from '../types.js';
11
+ import { failedResult } from '../remote-exec.js';
12
+ import { SANDBOX_PROVIDERS } from '../registry.js';
13
+
14
+ function requireFields(credentials: Record<string, string>): {
15
+ apiToken: string;
16
+ accountId: string;
17
+ } {
18
+ const apiToken = credentials.apiToken?.trim();
19
+ const accountId = credentials.accountId?.trim();
20
+ if (!apiToken || !accountId)
21
+ throw new Error('Cloudflare API token and account ID are both required.');
22
+ return { apiToken, accountId };
23
+ }
24
+
25
+ async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
26
+ const { apiToken, accountId } = requireFields(credentials);
27
+
28
+ const tokenRes = await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', {
29
+ headers: { Authorization: `Bearer ${apiToken}` },
30
+ });
31
+ if (!tokenRes.ok) throw new Error(`Cloudflare rejected the API token (${tokenRes.status}).`);
32
+
33
+ const accountRes = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}`, {
34
+ headers: { Authorization: `Bearer ${apiToken}` },
35
+ });
36
+ if (!accountRes.ok) throw new Error('This API token cannot see the given Cloudflare account ID.');
37
+ }
38
+
39
+ async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
40
+ const caveat = SANDBOX_PROVIDERS.cloudflare.executionCaveat;
41
+ if (!credentials.apiToken?.trim() || !credentials.accountId?.trim()) {
42
+ return {
43
+ status: 'missing-credentials',
44
+ backend: 'cloudflare',
45
+ error: 'Cloudflare API token and account ID are required.',
46
+ };
47
+ }
48
+ try {
49
+ await verifyCredentials(credentials);
50
+ return { status: 'unsupported', backend: 'cloudflare', error: caveat };
51
+ } catch (err) {
52
+ return {
53
+ status: 'error',
54
+ backend: 'cloudflare',
55
+ error: err instanceof Error ? err.message : 'Cloudflare health check failed.',
56
+ };
57
+ }
58
+ }
59
+
60
+ async function execute(
61
+ _request: ExecutionRequest,
62
+ _credentials: Record<string, string>,
63
+ ): Promise<ExecutionResult> {
64
+ return failedResult(SANDBOX_PROVIDERS.cloudflare.executionCaveat!, Date.now());
65
+ }
66
+
67
+ export const cloudflareAdapter: SandboxProviderAdapter = {
68
+ id: 'cloudflare',
69
+ verifyCredentials,
70
+ healthCheck,
71
+ execute,
72
+ };
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ const executeCommandMock = vi.fn();
4
+ const deleteMock = vi.fn();
5
+ const createMock = vi.fn();
6
+ const listMock = vi.fn();
7
+
8
+ vi.mock('@daytonaio/sdk', () => ({
9
+ Daytona: vi.fn().mockImplementation(function () {
10
+ return { create: createMock, list: listMock };
11
+ }),
12
+ }));
13
+
14
+ const { daytonaAdapter } = await import('./daytona.js');
15
+
16
+ beforeEach(() => {
17
+ executeCommandMock.mockReset();
18
+ deleteMock.mockReset();
19
+ createMock.mockReset();
20
+ listMock.mockReset();
21
+ createMock.mockResolvedValue({ process: { executeCommand: executeCommandMock }, delete: deleteMock });
22
+ });
23
+
24
+ describe('daytonaAdapter.verifyCredentials', () => {
25
+ it('requires an API key', async () => {
26
+ await expect(daytonaAdapter.verifyCredentials({})).rejects.toThrow('Daytona API key is required.');
27
+ });
28
+
29
+ it('uses list() as the cheap credential check', async () => {
30
+ const next = vi.fn().mockResolvedValue({ done: true, value: undefined });
31
+ listMock.mockReturnValue({ next });
32
+ await daytonaAdapter.verifyCredentials({ apiKey: 'dtn_1' });
33
+ expect(listMock).toHaveBeenCalledTimes(1);
34
+ expect(next).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
37
+
38
+ describe('daytonaAdapter.execute — merged stdout/stderr', () => {
39
+ it('puts successful output in stdout only', async () => {
40
+ executeCommandMock.mockResolvedValue({ exitCode: 0, result: 'all good\n' });
41
+ const result = await daytonaAdapter.execute({ code: 'print(1)', language: 'python' }, { apiKey: 'k' });
42
+ expect(result.stdout).toBe('all good\n');
43
+ expect(result.stderr).toBe('');
44
+ expect(result.exitCode).toBe(0);
45
+ });
46
+
47
+ it('mirrors the combined output into stderr when the command fails', async () => {
48
+ executeCommandMock.mockResolvedValue({ exitCode: 1, result: 'Traceback...\n' });
49
+ const result = await daytonaAdapter.execute({ code: 'raise ValueError()', language: 'python' }, { apiKey: 'k' });
50
+ expect(result.stdout).toBe('Traceback...\n');
51
+ expect(result.stderr).toBe('Traceback...\n');
52
+ expect(result.exitCode).toBe(1);
53
+ });
54
+
55
+ it('always deletes the sandbox afterward', async () => {
56
+ executeCommandMock.mockResolvedValue({ exitCode: 0, result: '' });
57
+ await daytonaAdapter.execute({ code: 'x=1', language: 'python' }, { apiKey: 'k' });
58
+ expect(deleteMock).toHaveBeenCalledTimes(1);
59
+ });
60
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Daytona adapter — https://www.daytona.io/docs/en/sandboxes/
3
+ *
4
+ * Daytona's `process.executeCommand` result doesn't document a separate
5
+ * stderr field (output comes back merged into `result`), so unlike the
6
+ * other adapters we can't cleanly split stdout/stderr — everything lands in
7
+ * stdout, and the combined output is mirrored into stderr only when the
8
+ * command actually failed (exitCode !== 0), so error output still surfaces
9
+ * where callers expect it.
10
+ */
11
+
12
+ import type {
13
+ SandboxHealthCheck,
14
+ ExecutionRequest,
15
+ ExecutionResult,
16
+ SandboxProviderAdapter,
17
+ } from '../types.js';
18
+ import {
19
+ DEFAULT_TIMEOUT_MS,
20
+ failedResult,
21
+ runScriptedExecution,
22
+ safeCleanup,
23
+ withTimeout,
24
+ } from '../remote-exec.js';
25
+
26
+ interface DaytonaSandbox {
27
+ process: {
28
+ executeCommand(
29
+ command: string,
30
+ cwd?: string,
31
+ env?: Record<string, string>,
32
+ timeout?: number,
33
+ ): Promise<{
34
+ exitCode?: number;
35
+ result?: string;
36
+ }>;
37
+ };
38
+ delete(): Promise<void>;
39
+ }
40
+
41
+ interface DaytonaClient {
42
+ create(opts?: Record<string, unknown>): Promise<DaytonaSandbox>;
43
+ list(): AsyncIterator<unknown>;
44
+ }
45
+
46
+ interface DaytonaModule {
47
+ Daytona: new (opts: { apiKey: string; apiUrl?: string }) => DaytonaClient;
48
+ }
49
+
50
+ async function loadSdk(): Promise<DaytonaModule> {
51
+ return (await import('@daytonaio/sdk')) as unknown as DaytonaModule;
52
+ }
53
+
54
+ function requireFields(credentials: Record<string, string>): { apiKey: string; apiUrl?: string } {
55
+ const apiKey = credentials.apiKey?.trim();
56
+ if (!apiKey) throw new Error('Daytona API key is required.');
57
+ return { apiKey, apiUrl: credentials.apiUrl?.trim() || undefined };
58
+ }
59
+
60
+ async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
61
+ const fields = requireFields(credentials);
62
+ const { Daytona } = await loadSdk();
63
+ const daytona = new Daytona(fields);
64
+ await daytona.list().next();
65
+ }
66
+
67
+ async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
68
+ if (!credentials.apiKey?.trim()) {
69
+ return {
70
+ status: 'missing-credentials',
71
+ backend: 'daytona',
72
+ error: 'Daytona API key is required.',
73
+ };
74
+ }
75
+ try {
76
+ await verifyCredentials(credentials);
77
+ return { status: 'ready', backend: 'daytona' };
78
+ } catch (err) {
79
+ return {
80
+ status: 'error',
81
+ backend: 'daytona',
82
+ error: err instanceof Error ? err.message : 'Daytona health check failed.',
83
+ };
84
+ }
85
+ }
86
+
87
+ async function execute(
88
+ request: ExecutionRequest,
89
+ credentials: Record<string, string>,
90
+ ): Promise<ExecutionResult> {
91
+ const startTime = Date.now();
92
+ let fields: { apiKey: string; apiUrl?: string };
93
+ try {
94
+ fields = requireFields(credentials);
95
+ } catch (err) {
96
+ return failedResult(
97
+ err instanceof Error ? err.message : 'Missing Daytona credentials.',
98
+ startTime,
99
+ );
100
+ }
101
+
102
+ const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
103
+ let sandbox: DaytonaSandbox | undefined;
104
+ try {
105
+ const { Daytona } = await loadSdk();
106
+ const daytona = new Daytona(fields);
107
+ const snapshot = credentials.snapshot?.trim();
108
+ sandbox = await withTimeout(
109
+ daytona.create(snapshot ? { snapshot } : { language: 'python' }),
110
+ timeoutMs + 30_000,
111
+ 'Daytona sandbox create',
112
+ );
113
+ const activeSandbox = sandbox;
114
+ return await withTimeout(
115
+ runScriptedExecution(async (script) => {
116
+ const result = await activeSandbox.process.executeCommand(
117
+ script,
118
+ undefined,
119
+ undefined,
120
+ timeoutMs / 1000,
121
+ );
122
+ const exitCode = result.exitCode ?? 0;
123
+ const output = result.result ?? '';
124
+ return { stdout: output, stderr: exitCode !== 0 ? output : '', exitCode };
125
+ }, request),
126
+ timeoutMs + 15_000,
127
+ 'Daytona execution',
128
+ );
129
+ } catch (err) {
130
+ return failedResult(
131
+ err instanceof Error ? err.message : 'Daytona execution failed.',
132
+ startTime,
133
+ );
134
+ } finally {
135
+ if (sandbox) await safeCleanup(() => sandbox!.delete());
136
+ }
137
+ }
138
+
139
+ export const daytonaAdapter: SandboxProviderAdapter = {
140
+ id: 'daytona',
141
+ verifyCredentials,
142
+ healthCheck,
143
+ execute,
144
+ };
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ const listMock = vi.fn();
4
+ const runMock = vi.fn();
5
+ const killMock = vi.fn();
6
+ const createMock = vi.fn();
7
+
8
+ vi.mock('e2b', () => ({
9
+ Sandbox: {
10
+ list: listMock,
11
+ create: createMock,
12
+ },
13
+ }));
14
+
15
+ const { e2bAdapter } = await import('./e2b.js');
16
+
17
+ beforeEach(() => {
18
+ listMock.mockReset();
19
+ runMock.mockReset();
20
+ killMock.mockReset();
21
+ createMock.mockReset();
22
+ createMock.mockResolvedValue({ commands: { run: runMock }, kill: killMock });
23
+ });
24
+
25
+ describe('e2bAdapter.verifyCredentials', () => {
26
+ it('rejects without calling the SDK when the API key is missing', async () => {
27
+ await expect(e2bAdapter.verifyCredentials({})).rejects.toThrow('E2B API key is required.');
28
+ expect(listMock).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it('lists sandboxes as a cheap credential check', async () => {
32
+ const nextItems = vi.fn().mockResolvedValue([]);
33
+ listMock.mockReturnValue({ nextItems });
34
+ await e2bAdapter.verifyCredentials({ apiKey: 'e2b_test' });
35
+ expect(listMock).toHaveBeenCalledWith({ apiKey: 'e2b_test' });
36
+ expect(nextItems).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it('propagates the SDK error on an invalid key', async () => {
40
+ listMock.mockReturnValue({ nextItems: vi.fn().mockRejectedValue(new Error('401 unauthorized')) });
41
+ await expect(e2bAdapter.verifyCredentials({ apiKey: 'bad' })).rejects.toThrow('401 unauthorized');
42
+ });
43
+ });
44
+
45
+ describe('e2bAdapter.healthCheck', () => {
46
+ it('reports missing-credentials without touching the SDK', async () => {
47
+ const result = await e2bAdapter.healthCheck({});
48
+ expect(result).toEqual({ status: 'missing-credentials', backend: 'e2b', error: 'E2B API key is required.' });
49
+ expect(listMock).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('reports ready when verification succeeds', async () => {
53
+ listMock.mockReturnValue({ nextItems: vi.fn().mockResolvedValue([]) });
54
+ const result = await e2bAdapter.healthCheck({ apiKey: 'good' });
55
+ expect(result.status).toBe('ready');
56
+ });
57
+ });
58
+
59
+ describe('e2bAdapter.execute', () => {
60
+ it('fails fast without creating a sandbox when the API key is missing', async () => {
61
+ const result = await e2bAdapter.execute({ code: 'print(1)', language: 'python' }, {});
62
+ expect(result.exitCode).toBe(1);
63
+ expect(result.stderr).toContain('E2B API key is required.');
64
+ expect(createMock).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('creates a sandbox, runs the script, and always kills it afterward', async () => {
68
+ runMock.mockResolvedValue({ stdout: 'hi\n', stderr: '', exitCode: 0 });
69
+ const result = await e2bAdapter.execute({ code: 'print("hi")', language: 'python' }, { apiKey: 'key' });
70
+
71
+ expect(createMock).toHaveBeenCalled();
72
+ expect(runMock).toHaveBeenCalledTimes(1);
73
+ expect(result.stdout).toBe('hi\n');
74
+ expect(result.exitCode).toBe(0);
75
+ expect(killMock).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it('starts from a configured snapshot when one is supplied', async () => {
79
+ runMock.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
80
+
81
+ await e2bAdapter.execute(
82
+ { code: 'print(1)', language: 'python' },
83
+ { apiKey: 'key', snapshotId: 'larkup-python:latest' },
84
+ );
85
+
86
+ expect(createMock).toHaveBeenCalledWith(
87
+ 'larkup-python:latest',
88
+ expect.objectContaining({ allowInternetAccess: false }),
89
+ );
90
+ });
91
+
92
+ it('still kills the sandbox and returns a failed result when the run call throws', async () => {
93
+ runMock.mockRejectedValue(new Error('command failed'));
94
+ const result = await e2bAdapter.execute({ code: 'x=1', language: 'python' }, { apiKey: 'key' });
95
+
96
+ expect(result.exitCode).toBe(1);
97
+ expect(result.stderr).toContain('command failed');
98
+ expect(killMock).toHaveBeenCalledTimes(1);
99
+ });
100
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * E2B adapter — https://docs.e2b.dev/
3
+ *
4
+ * Uses the base `e2b` SDK (not `@e2b/code-interpreter`) so execution stays a
5
+ * plain "run this shell script, get stdout/stderr/exitCode" call, matching
6
+ * every other remote adapter and letting them share `runScriptedExecution`.
7
+ */
8
+
9
+ import type {
10
+ SandboxHealthCheck,
11
+ ExecutionRequest,
12
+ ExecutionResult,
13
+ SandboxProviderAdapter,
14
+ } from '../types.js';
15
+ import {
16
+ DEFAULT_TIMEOUT_MS,
17
+ failedResult,
18
+ runScriptedExecution,
19
+ safeCleanup,
20
+ withTimeout,
21
+ } from '../remote-exec.js';
22
+
23
+ interface E2bSandbox {
24
+ commands: {
25
+ run(
26
+ script: string,
27
+ opts?: { timeoutMs?: number },
28
+ ): Promise<{ stdout?: string; stderr?: string; exitCode?: number }>;
29
+ };
30
+ kill(): Promise<void>;
31
+ }
32
+
33
+ interface E2bModule {
34
+ Sandbox: {
35
+ create(
36
+ template: string,
37
+ opts: { apiKey: string; timeoutMs?: number; allowInternetAccess?: boolean },
38
+ ): Promise<E2bSandbox>;
39
+ create(opts: {
40
+ apiKey: string;
41
+ timeoutMs?: number;
42
+ allowInternetAccess?: boolean;
43
+ }): Promise<E2bSandbox>;
44
+ list(opts: { apiKey: string }): { nextItems(): Promise<unknown[]> };
45
+ };
46
+ }
47
+
48
+ async function loadSdk(): Promise<E2bModule> {
49
+ return (await import('e2b')) as unknown as E2bModule;
50
+ }
51
+
52
+ async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
53
+ const apiKey = credentials.apiKey?.trim();
54
+ if (!apiKey) throw new Error('E2B API key is required.');
55
+ const { Sandbox } = await loadSdk();
56
+ await Sandbox.list({ apiKey }).nextItems();
57
+ }
58
+
59
+ async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
60
+ if (!credentials.apiKey?.trim()) {
61
+ return { status: 'missing-credentials', backend: 'e2b', error: 'E2B API key is required.' };
62
+ }
63
+ try {
64
+ await verifyCredentials(credentials);
65
+ return { status: 'ready', backend: 'e2b' };
66
+ } catch (err) {
67
+ return {
68
+ status: 'error',
69
+ backend: 'e2b',
70
+ error: err instanceof Error ? err.message : 'E2B health check failed.',
71
+ };
72
+ }
73
+ }
74
+
75
+ async function execute(
76
+ request: ExecutionRequest,
77
+ credentials: Record<string, string>,
78
+ ): Promise<ExecutionResult> {
79
+ const startTime = Date.now();
80
+ const apiKey = credentials.apiKey?.trim();
81
+ if (!apiKey) return failedResult('E2B API key is required.', startTime);
82
+
83
+ const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
84
+ let sandbox: E2bSandbox | undefined;
85
+ try {
86
+ const { Sandbox } = await loadSdk();
87
+ const options = { apiKey, timeoutMs: timeoutMs + 60_000, allowInternetAccess: false };
88
+ const snapshotId = credentials.snapshotId?.trim();
89
+ sandbox = await withTimeout(
90
+ snapshotId ? Sandbox.create(snapshotId, options) : Sandbox.create(options),
91
+ timeoutMs + 30_000,
92
+ 'E2B sandbox create',
93
+ );
94
+ const activeSandbox = sandbox;
95
+ return await runScriptedExecution(async (script) => {
96
+ const result = await activeSandbox.commands.run(script, { timeoutMs });
97
+ return {
98
+ stdout: result.stdout ?? '',
99
+ stderr: result.stderr ?? '',
100
+ exitCode: result.exitCode ?? 0,
101
+ };
102
+ }, request);
103
+ } catch (err) {
104
+ return failedResult(err instanceof Error ? err.message : 'E2B execution failed.', startTime);
105
+ } finally {
106
+ if (sandbox) await safeCleanup(() => sandbox!.kill());
107
+ }
108
+ }
109
+
110
+ export const e2bAdapter: SandboxProviderAdapter = {
111
+ id: 'e2b',
112
+ verifyCredentials,
113
+ healthCheck,
114
+ execute,
115
+ };