@larkup/sandbox 0.1.1 → 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.
- package/.turbo/turbo-build.log +2 -4
- package/CHANGELOG.md +12 -0
- package/dist/docker-runner.js +35 -21
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/local-runner.d.ts +5 -0
- package/dist/local-runner.js +176 -0
- package/dist/mime.d.ts +1 -0
- package/dist/mime.js +16 -0
- package/dist/providers/browserbase.d.ts +12 -0
- package/dist/providers/browserbase.js +61 -0
- package/dist/providers/cloudflare.d.ts +5 -0
- package/dist/providers/cloudflare.js +55 -0
- package/dist/providers/daytona.d.ts +12 -0
- package/dist/providers/daytona.js +84 -0
- package/dist/providers/e2b.d.ts +9 -0
- package/dist/providers/e2b.js +70 -0
- package/dist/providers/flyio.d.ts +9 -0
- package/dist/providers/flyio.js +98 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.js +26 -0
- package/dist/providers/modal.d.ts +8 -0
- package/dist/providers/modal.js +96 -0
- package/dist/providers/northflank.d.ts +9 -0
- package/dist/providers/northflank.js +122 -0
- package/dist/providers/vercel.d.ts +9 -0
- package/dist/providers/vercel.js +79 -0
- package/dist/providers/webcontainers.d.ts +13 -0
- package/dist/providers/webcontainers.js +28 -0
- package/dist/registry.d.ts +20 -0
- package/dist/registry.js +281 -0
- package/dist/remote-exec.d.ts +42 -0
- package/dist/remote-exec.js +144 -0
- package/dist/sandbox-manager.d.ts +5 -2
- package/dist/sandbox-manager.js +67 -46
- package/dist/types.d.ts +58 -9
- package/dist/types.js +1 -1
- package/package.json +23 -6
- package/src/docker-runner.ts +39 -27
- package/src/index.ts +11 -2
- package/src/local-runner.test.ts +22 -0
- package/src/local-runner.ts +199 -0
- package/src/mime.ts +17 -0
- package/src/providers/browserbase.test.ts +55 -0
- package/src/providers/browserbase.ts +88 -0
- package/src/providers/cloudflare.test.ts +63 -0
- package/src/providers/cloudflare.ts +72 -0
- package/src/providers/daytona.test.ts +60 -0
- package/src/providers/daytona.ts +144 -0
- package/src/providers/e2b.test.ts +100 -0
- package/src/providers/e2b.ts +115 -0
- package/src/providers/flyio.test.ts +69 -0
- package/src/providers/flyio.ts +148 -0
- package/src/providers/index.ts +42 -0
- package/src/providers/modal.test.ts +107 -0
- package/src/providers/modal.ts +167 -0
- package/src/providers/northflank.test.ts +110 -0
- package/src/providers/northflank.ts +199 -0
- package/src/providers/vercel.test.ts +84 -0
- package/src/providers/vercel.ts +143 -0
- package/src/providers/webcontainers.test.ts +21 -0
- package/src/providers/webcontainers.ts +36 -0
- package/src/registry.test.ts +84 -0
- package/src/registry.ts +308 -0
- package/src/remote-exec.test.ts +155 -0
- package/src/remote-exec.ts +190 -0
- package/src/sandbox-manager.test.ts +74 -0
- package/src/sandbox-manager.ts +71 -51
- package/src/types.ts +81 -23
- package/tsconfig.build.json +4 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Northflank adapter — https://northflank.com/docs/v1/application/sandboxes/sandboxes-on-northflank
|
|
3
|
+
*
|
|
4
|
+
* Northflank has no dedicated "Sandbox" primitive — a sandbox here is a
|
|
5
|
+
* short-lived service deployment (`sleep infinity` container) we exec
|
|
6
|
+
* commands into over a session, then tear down.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import type {
|
|
11
|
+
SandboxHealthCheck,
|
|
12
|
+
ExecutionRequest,
|
|
13
|
+
ExecutionResult,
|
|
14
|
+
SandboxProviderAdapter,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_TIMEOUT_MS,
|
|
18
|
+
failedResult,
|
|
19
|
+
runScriptedExecution,
|
|
20
|
+
safeCleanup,
|
|
21
|
+
withTimeout,
|
|
22
|
+
} from '../remote-exec.js';
|
|
23
|
+
|
|
24
|
+
interface NorthflankExecHandle {
|
|
25
|
+
stdOut: { on(event: 'data', cb: (chunk: Buffer | string) => void): void };
|
|
26
|
+
stdErr: { on(event: 'data', cb: (chunk: Buffer | string) => void): void };
|
|
27
|
+
waitForCommandResult(): Promise<{ exitCode?: number }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface NorthflankApiClient {
|
|
31
|
+
create: { service: { deployment(args: unknown): Promise<unknown> } };
|
|
32
|
+
get: {
|
|
33
|
+
project(args: { parameters: { projectId: string } }): Promise<unknown>;
|
|
34
|
+
service(args: unknown): Promise<{ data?: { status?: string } }>;
|
|
35
|
+
};
|
|
36
|
+
delete: { service(args: unknown): Promise<unknown> };
|
|
37
|
+
exec: {
|
|
38
|
+
execServiceSession(
|
|
39
|
+
target: { projectId: string; serviceId: string },
|
|
40
|
+
opts: { shell: string; command: string },
|
|
41
|
+
): Promise<NorthflankExecHandle>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface NorthflankModule {
|
|
46
|
+
ApiClient: new (
|
|
47
|
+
ctx: unknown,
|
|
48
|
+
opts?: { throwErrorOnHttpErrorCode?: boolean },
|
|
49
|
+
) => NorthflankApiClient;
|
|
50
|
+
ApiClientInMemoryContextProvider: new () => {
|
|
51
|
+
addContext(opts: { name: string; token: string }): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadSdk(): Promise<NorthflankModule> {
|
|
56
|
+
return (await import('@northflank/js-client')) as unknown as NorthflankModule;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireFields(credentials: Record<string, string>): { token: string; projectId: string } {
|
|
60
|
+
const token = credentials.token?.trim();
|
|
61
|
+
const projectId = credentials.projectId?.trim();
|
|
62
|
+
if (!token || !projectId)
|
|
63
|
+
throw new Error('Northflank API token and project ID are both required.');
|
|
64
|
+
return { token, projectId };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function buildClient(token: string): Promise<NorthflankApiClient> {
|
|
68
|
+
const { ApiClient, ApiClientInMemoryContextProvider } = await loadSdk();
|
|
69
|
+
const ctx = new ApiClientInMemoryContextProvider();
|
|
70
|
+
await ctx.addContext({ name: 'larkup', token });
|
|
71
|
+
return new ApiClient(ctx, { throwErrorOnHttpErrorCode: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
|
|
75
|
+
const { token, projectId } = requireFields(credentials);
|
|
76
|
+
const client = await buildClient(token);
|
|
77
|
+
await withTimeout(
|
|
78
|
+
client.get.project({ parameters: { projectId } }),
|
|
79
|
+
20_000,
|
|
80
|
+
'Northflank credential check',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
|
|
85
|
+
if (!credentials.token?.trim() || !credentials.projectId?.trim()) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'missing-credentials',
|
|
88
|
+
backend: 'northflank',
|
|
89
|
+
error: 'Northflank API token and project ID are required.',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await verifyCredentials(credentials);
|
|
94
|
+
return { status: 'ready', backend: 'northflank' };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
status: 'error',
|
|
98
|
+
backend: 'northflank',
|
|
99
|
+
error: err instanceof Error ? err.message : 'Northflank health check failed.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function waitForServiceReady(
|
|
105
|
+
client: NorthflankApiClient,
|
|
106
|
+
projectId: string,
|
|
107
|
+
serviceId: string,
|
|
108
|
+
timeoutMs: number,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
const service = await client.get.service({ parameters: { projectId, serviceId } });
|
|
113
|
+
const status = service.data?.status;
|
|
114
|
+
if (status === 'COMPLETED' || status === undefined) return; // running, or status field not modeled — proceed
|
|
115
|
+
if (status === 'FAILED') throw new Error('Northflank sandbox service failed to start.');
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
117
|
+
}
|
|
118
|
+
throw new Error('Timed out waiting for the Northflank sandbox service to become ready.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function execute(
|
|
122
|
+
request: ExecutionRequest,
|
|
123
|
+
credentials: Record<string, string>,
|
|
124
|
+
): Promise<ExecutionResult> {
|
|
125
|
+
const startTime = Date.now();
|
|
126
|
+
let fields: { token: string; projectId: string };
|
|
127
|
+
try {
|
|
128
|
+
fields = requireFields(credentials);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return failedResult(
|
|
131
|
+
err instanceof Error ? err.message : 'Missing Northflank credentials.',
|
|
132
|
+
startTime,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
137
|
+
const serviceId = `larkup-${randomUUID().slice(0, 8)}`;
|
|
138
|
+
const { projectId } = fields;
|
|
139
|
+
let client: NorthflankApiClient | undefined;
|
|
140
|
+
try {
|
|
141
|
+
client = await buildClient(fields.token);
|
|
142
|
+
await withTimeout(
|
|
143
|
+
client.create.service.deployment({
|
|
144
|
+
parameters: { projectId },
|
|
145
|
+
data: {
|
|
146
|
+
name: serviceId,
|
|
147
|
+
billing: { deploymentPlan: 'nf-compute-200' },
|
|
148
|
+
deployment: {
|
|
149
|
+
instances: 1,
|
|
150
|
+
docker: { configType: 'customCommand', customCommand: 'sleep infinity' },
|
|
151
|
+
external: { imagePath: 'ubuntu:22.04' },
|
|
152
|
+
storage: { ephemeralStorage: { storageSize: 2048 } },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
30_000,
|
|
157
|
+
'Northflank service create',
|
|
158
|
+
);
|
|
159
|
+
await waitForServiceReady(client, projectId, serviceId, 60_000);
|
|
160
|
+
|
|
161
|
+
return await withTimeout(
|
|
162
|
+
runScriptedExecution(async (script) => {
|
|
163
|
+
const activeClient = client!;
|
|
164
|
+
const handle = await activeClient.exec.execServiceSession(
|
|
165
|
+
{ projectId, serviceId },
|
|
166
|
+
{ shell: 'bash -c', command: script },
|
|
167
|
+
);
|
|
168
|
+
const stdoutChunks: string[] = [];
|
|
169
|
+
const stderrChunks: string[] = [];
|
|
170
|
+
handle.stdOut.on('data', (chunk) => stdoutChunks.push(chunk.toString()));
|
|
171
|
+
handle.stdErr.on('data', (chunk) => stderrChunks.push(chunk.toString()));
|
|
172
|
+
const result = await handle.waitForCommandResult();
|
|
173
|
+
return {
|
|
174
|
+
stdout: stdoutChunks.join(''),
|
|
175
|
+
stderr: stderrChunks.join(''),
|
|
176
|
+
exitCode: result.exitCode ?? 0,
|
|
177
|
+
};
|
|
178
|
+
}, request),
|
|
179
|
+
timeoutMs + 15_000,
|
|
180
|
+
'Northflank execution',
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return failedResult(
|
|
184
|
+
err instanceof Error ? err.message : 'Northflank execution failed.',
|
|
185
|
+
startTime,
|
|
186
|
+
);
|
|
187
|
+
} finally {
|
|
188
|
+
if (client) {
|
|
189
|
+
await safeCleanup(() => client!.delete.service({ parameters: { projectId, serviceId } }));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const northflankAdapter: SandboxProviderAdapter = {
|
|
195
|
+
id: 'northflank',
|
|
196
|
+
verifyCredentials,
|
|
197
|
+
healthCheck,
|
|
198
|
+
execute,
|
|
199
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const runCommandMock = vi.fn();
|
|
4
|
+
const stopMock = vi.fn();
|
|
5
|
+
const createMock = vi.fn();
|
|
6
|
+
const listMock = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock('@vercel/sandbox', () => ({
|
|
9
|
+
Sandbox: { create: createMock, list: listMock },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { vercelAdapter } = await import('./vercel.js');
|
|
13
|
+
|
|
14
|
+
const fields = { token: 'tok', teamId: 'team_1', projectId: 'prj_1' };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
runCommandMock.mockReset();
|
|
18
|
+
stopMock.mockReset();
|
|
19
|
+
createMock.mockReset();
|
|
20
|
+
listMock.mockReset();
|
|
21
|
+
createMock.mockResolvedValue({ runCommand: runCommandMock, stop: stopMock });
|
|
22
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('vercelAdapter.verifyCredentials', () => {
|
|
26
|
+
it('requires token, teamId, and projectId', async () => {
|
|
27
|
+
await expect(vercelAdapter.verifyCredentials({ token: 'tok' })).rejects.toThrow(
|
|
28
|
+
'Vercel access token, team ID, and project ID are all required.',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('lists sandboxes with the submitted project credentials', async () => {
|
|
33
|
+
const toArray = vi.fn().mockResolvedValue([]);
|
|
34
|
+
listMock.mockResolvedValue({ toArray });
|
|
35
|
+
await vercelAdapter.verifyCredentials(fields);
|
|
36
|
+
expect(listMock).toHaveBeenCalledWith(fields);
|
|
37
|
+
expect(toArray).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('surfaces a Vercel Sandbox authorization failure', async () => {
|
|
41
|
+
listMock.mockRejectedValue(new Error('Forbidden'));
|
|
42
|
+
await expect(vercelAdapter.verifyCredentials(fields)).rejects.toThrow('Forbidden');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('vercelAdapter.execute', () => {
|
|
47
|
+
it('runs the script through bash -c and reads stdout/stderr as functions', async () => {
|
|
48
|
+
runCommandMock.mockResolvedValue({
|
|
49
|
+
exitCode: 0,
|
|
50
|
+
stdout: async () => 'result\n',
|
|
51
|
+
stderr: async () => '',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = await vercelAdapter.execute({ code: 'print(1)', language: 'python' }, fields);
|
|
55
|
+
|
|
56
|
+
expect(createMock).toHaveBeenCalledWith(expect.objectContaining(fields));
|
|
57
|
+
expect(runCommandMock).toHaveBeenCalledWith('bash', ['-c', expect.stringContaining('run.py')]);
|
|
58
|
+
expect(result.stdout).toBe('result\n');
|
|
59
|
+
expect(stopMock).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('restores a configured snapshot for each execution', async () => {
|
|
63
|
+
runCommandMock.mockResolvedValue({
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
stdout: async () => '',
|
|
66
|
+
stderr: async () => '',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await vercelAdapter.execute(
|
|
70
|
+
{ code: 'print(1)', language: 'python' },
|
|
71
|
+
{ ...fields, snapshotId: 'snap_1' },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(createMock).toHaveBeenCalledWith(
|
|
75
|
+
expect.objectContaining({ source: { type: 'snapshot', snapshotId: 'snap_1' } }),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns a failed result without creating a sandbox when credentials are incomplete', async () => {
|
|
80
|
+
const result = await vercelAdapter.execute({ code: 'x=1', language: 'python' }, { token: 'tok' });
|
|
81
|
+
expect(result.exitCode).toBe(1);
|
|
82
|
+
expect(createMock).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Sandbox adapter — https://vercel.com/docs/sandbox
|
|
3
|
+
*
|
|
4
|
+
* Uses the access-token auth path (token + team + project) rather than the
|
|
5
|
+
* OIDC path, since credentials here come from the Settings UI, not a
|
|
6
|
+
* Vercel-managed environment.
|
|
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 VercelCommandResult {
|
|
24
|
+
exitCode: number;
|
|
25
|
+
stdout(): Promise<string>;
|
|
26
|
+
stderr(): Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface VercelSandbox {
|
|
30
|
+
runCommand(cmd: string, args: string[]): Promise<VercelCommandResult>;
|
|
31
|
+
stop(): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface VercelSandboxModule {
|
|
35
|
+
Sandbox: {
|
|
36
|
+
create(opts: {
|
|
37
|
+
token: string;
|
|
38
|
+
teamId: string;
|
|
39
|
+
projectId: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
source?: { type: 'snapshot'; snapshotId: string };
|
|
42
|
+
}): Promise<VercelSandbox>;
|
|
43
|
+
list(opts: {
|
|
44
|
+
token: string;
|
|
45
|
+
teamId: string;
|
|
46
|
+
projectId: string;
|
|
47
|
+
}): Promise<{ toArray(): Promise<unknown[]> }>;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function loadSdk(): Promise<VercelSandboxModule> {
|
|
52
|
+
return (await import('@vercel/sandbox')) as unknown as VercelSandboxModule;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function requireFields(credentials: Record<string, string>): {
|
|
56
|
+
token: string;
|
|
57
|
+
teamId: string;
|
|
58
|
+
projectId: string;
|
|
59
|
+
} {
|
|
60
|
+
const token = credentials.token?.trim();
|
|
61
|
+
const teamId = credentials.teamId?.trim();
|
|
62
|
+
const projectId = credentials.projectId?.trim();
|
|
63
|
+
if (!token || !teamId || !projectId) {
|
|
64
|
+
throw new Error('Vercel access token, team ID, and project ID are all required.');
|
|
65
|
+
}
|
|
66
|
+
return { token, teamId, projectId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function verifyCredentials(credentials: Record<string, string>): Promise<void> {
|
|
70
|
+
const { token, teamId, projectId } = requireFields(credentials);
|
|
71
|
+
const { Sandbox } = await loadSdk();
|
|
72
|
+
await (await Sandbox.list({ token, teamId, projectId })).toArray();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck> {
|
|
76
|
+
try {
|
|
77
|
+
await verifyCredentials(credentials);
|
|
78
|
+
return { status: 'ready', backend: 'vercel' };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const missing = !credentials.token || !credentials.teamId || !credentials.projectId;
|
|
81
|
+
return {
|
|
82
|
+
status: missing ? 'missing-credentials' : 'error',
|
|
83
|
+
backend: 'vercel',
|
|
84
|
+
error: err instanceof Error ? err.message : 'Vercel Sandbox health check failed.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function execute(
|
|
90
|
+
request: ExecutionRequest,
|
|
91
|
+
credentials: Record<string, string>,
|
|
92
|
+
): Promise<ExecutionResult> {
|
|
93
|
+
const startTime = Date.now();
|
|
94
|
+
let fields: { token: string; teamId: string; projectId: string };
|
|
95
|
+
try {
|
|
96
|
+
fields = requireFields(credentials);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return failedResult(
|
|
99
|
+
err instanceof Error ? err.message : 'Missing Vercel credentials.',
|
|
100
|
+
startTime,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
let sandbox: VercelSandbox | undefined;
|
|
106
|
+
try {
|
|
107
|
+
const { Sandbox } = await loadSdk();
|
|
108
|
+
const snapshotId = credentials.snapshotId?.trim();
|
|
109
|
+
sandbox = await withTimeout(
|
|
110
|
+
Sandbox.create({
|
|
111
|
+
...fields,
|
|
112
|
+
timeout: timeoutMs + 60_000,
|
|
113
|
+
...(snapshotId ? { source: { type: 'snapshot' as const, snapshotId } } : {}),
|
|
114
|
+
}),
|
|
115
|
+
timeoutMs + 30_000,
|
|
116
|
+
'Vercel sandbox create',
|
|
117
|
+
);
|
|
118
|
+
const activeSandbox = sandbox;
|
|
119
|
+
return await withTimeout(
|
|
120
|
+
runScriptedExecution(async (script) => {
|
|
121
|
+
const result = await activeSandbox.runCommand('bash', ['-c', script]);
|
|
122
|
+
const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]);
|
|
123
|
+
return { stdout, stderr, exitCode: result.exitCode };
|
|
124
|
+
}, request),
|
|
125
|
+
timeoutMs + 15_000,
|
|
126
|
+
'Vercel Sandbox execution',
|
|
127
|
+
);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return failedResult(
|
|
130
|
+
err instanceof Error ? err.message : 'Vercel Sandbox execution failed.',
|
|
131
|
+
startTime,
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
if (sandbox) await safeCleanup(() => sandbox!.stop());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const vercelAdapter: SandboxProviderAdapter = {
|
|
139
|
+
id: 'vercel',
|
|
140
|
+
verifyCredentials,
|
|
141
|
+
healthCheck,
|
|
142
|
+
execute,
|
|
143
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { webcontainersAdapter } from './webcontainers.js';
|
|
3
|
+
|
|
4
|
+
describe('webcontainersAdapter', () => {
|
|
5
|
+
it('execute() always fails — WebContainers is browser-only, no Node.js entry point', async () => {
|
|
6
|
+
const result = await webcontainersAdapter.execute({ code: 'console.log(1)', language: 'javascript' }, {});
|
|
7
|
+
expect(result.exitCode).toBe(1);
|
|
8
|
+
expect(result.stderr).toContain('cross-origin-isolated browser tab');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('verifyCredentials always throws, since nothing here is server-checkable', async () => {
|
|
12
|
+
await expect(webcontainersAdapter.verifyCredentials({})).rejects.toThrow('no server-side credential check');
|
|
13
|
+
await expect(webcontainersAdapter.verifyCredentials({ apiKey: 'wc_live_key' })).rejects.toThrow();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('healthCheck reports status "unsupported"', async () => {
|
|
17
|
+
const result = await webcontainersAdapter.healthCheck({});
|
|
18
|
+
expect(result.status).toBe('unsupported');
|
|
19
|
+
expect(result.error).toContain('SharedArrayBuffer');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebContainers adapter — https://webcontainers.io/
|
|
3
|
+
*
|
|
4
|
+
* `@webcontainer/api` boots a WebAssembly Node.js runtime that only runs
|
|
5
|
+
* inside a cross-origin-isolated browser tab (it needs SharedArrayBuffer) —
|
|
6
|
+
* there is no Node.js/server-side entry point at all, so `execute()` fails
|
|
7
|
+
* fast. The optional "license key" field is a StackBlitz-issued commercial
|
|
8
|
+
* key consumed by `WebContainer.configureAPIKey()` in the browser; there is
|
|
9
|
+
* no server-reachable endpoint to validate it against, so `verifyCredentials`
|
|
10
|
+
* says so honestly rather than reporting a false positive.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SandboxHealthCheck, ExecutionRequest, ExecutionResult, SandboxProviderAdapter } from '../types.js';
|
|
14
|
+
import { failedResult } from '../remote-exec.js';
|
|
15
|
+
import { SANDBOX_PROVIDERS } from '../registry.js';
|
|
16
|
+
|
|
17
|
+
async function verifyCredentials(_credentials: Record<string, string>): Promise<void> {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'WebContainers has no server-side credential check — the license key is only validated by StackBlitz when WebContainer.boot() runs in the browser.',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function healthCheck(_credentials: Record<string, string>): Promise<SandboxHealthCheck> {
|
|
24
|
+
return { status: 'unsupported', backend: 'webcontainers', error: SANDBOX_PROVIDERS.webcontainers.executionCaveat };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function execute(_request: ExecutionRequest, _credentials: Record<string, string>): Promise<ExecutionResult> {
|
|
28
|
+
return failedResult(SANDBOX_PROVIDERS.webcontainers.executionCaveat!, Date.now());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const webcontainersAdapter: SandboxProviderAdapter = {
|
|
32
|
+
id: 'webcontainers',
|
|
33
|
+
verifyCredentials,
|
|
34
|
+
healthCheck,
|
|
35
|
+
execute,
|
|
36
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SANDBOX_PROVIDER_LIST,
|
|
4
|
+
getSandboxProvider,
|
|
5
|
+
validateSandboxCredentials,
|
|
6
|
+
} from './registry.js';
|
|
7
|
+
|
|
8
|
+
const EXPECTED_PROVIDER_IDS = [
|
|
9
|
+
'e2b',
|
|
10
|
+
'vercel',
|
|
11
|
+
'modal',
|
|
12
|
+
'daytona',
|
|
13
|
+
'browserbase',
|
|
14
|
+
'flyio',
|
|
15
|
+
'northflank',
|
|
16
|
+
'cloudflare',
|
|
17
|
+
'webcontainers',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('SANDBOX_PROVIDER_LIST', () => {
|
|
21
|
+
it('registers exactly the nine remote providers', () => {
|
|
22
|
+
expect(SANDBOX_PROVIDER_LIST.map((p) => p.id).sort()).toEqual(
|
|
23
|
+
[...EXPECTED_PROVIDER_IDS].sort(),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('gives every provider a label, docs link, and public icon path', () => {
|
|
28
|
+
for (const provider of SANDBOX_PROVIDER_LIST) {
|
|
29
|
+
expect(provider.label.length).toBeGreaterThan(0);
|
|
30
|
+
expect(provider.docsUrl).toMatch(/^https:\/\//);
|
|
31
|
+
expect(provider.icon.startsWith('/')).toBe(true);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('requires an executionCaveat exactly when executionSupport is unsupported', () => {
|
|
36
|
+
for (const provider of SANDBOX_PROVIDER_LIST) {
|
|
37
|
+
if (provider.executionSupport === 'unsupported') {
|
|
38
|
+
expect(
|
|
39
|
+
provider.executionCaveat,
|
|
40
|
+
`${provider.id} should explain its limitation`,
|
|
41
|
+
).toBeTruthy();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('flags browserbase, cloudflare, and webcontainers as not general code-execution backends', () => {
|
|
47
|
+
const unsupported = SANDBOX_PROVIDER_LIST.filter(
|
|
48
|
+
(p) => p.executionSupport === 'unsupported',
|
|
49
|
+
).map((p) => p.id);
|
|
50
|
+
expect(unsupported.sort()).toEqual(['browserbase', 'cloudflare', 'webcontainers']);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getSandboxProvider', () => {
|
|
55
|
+
it('returns undefined for local, docker, and custom, which are not registry-backed', () => {
|
|
56
|
+
expect(getSandboxProvider('local')).toBeUndefined();
|
|
57
|
+
expect(getSandboxProvider('docker')).toBeUndefined();
|
|
58
|
+
expect(getSandboxProvider('custom')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns the matching descriptor for a registered provider', () => {
|
|
62
|
+
expect(getSandboxProvider('e2b')?.label).toBe('E2B');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('validateSandboxCredentials', () => {
|
|
67
|
+
it('flags every missing required field', () => {
|
|
68
|
+
const vercel = getSandboxProvider('vercel')!;
|
|
69
|
+
const errors = validateSandboxCredentials(vercel, {});
|
|
70
|
+
expect(Object.keys(errors).sort()).toEqual(['projectId', 'teamId', 'token']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('passes once all required fields are present, ignoring optional ones', () => {
|
|
74
|
+
const daytona = getSandboxProvider('daytona')!;
|
|
75
|
+
const errors = validateSandboxCredentials(daytona, { apiKey: 'dtn_123' });
|
|
76
|
+
expect(errors).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('treats whitespace-only values as missing', () => {
|
|
80
|
+
const e2b = getSandboxProvider('e2b')!;
|
|
81
|
+
const errors = validateSandboxCredentials(e2b, { apiKey: ' ' });
|
|
82
|
+
expect(errors.apiKey).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
});
|