@larkup/sandbox 0.1.2 → 0.1.4
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 -2
- package/.turbo/turbo-test.log +26 -0
- package/.turbo/turbo-type-check.log +4 -0
- package/CHANGELOG.md +14 -0
- package/dist/docker-runner.js +27 -15
- 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 +21 -4
- package/src/docker-runner.ts +29 -19
- 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 +71 -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,70 @@
|
|
|
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
|
+
import { DEFAULT_TIMEOUT_MS, failedResult, runScriptedExecution, safeCleanup, withTimeout, } from '../remote-exec.js';
|
|
9
|
+
async function loadSdk() {
|
|
10
|
+
return (await import('e2b'));
|
|
11
|
+
}
|
|
12
|
+
async function verifyCredentials(credentials) {
|
|
13
|
+
const apiKey = credentials.apiKey?.trim();
|
|
14
|
+
if (!apiKey)
|
|
15
|
+
throw new Error('E2B API key is required.');
|
|
16
|
+
const { Sandbox } = await loadSdk();
|
|
17
|
+
await Sandbox.list({ apiKey }).nextItems();
|
|
18
|
+
}
|
|
19
|
+
async function healthCheck(credentials) {
|
|
20
|
+
if (!credentials.apiKey?.trim()) {
|
|
21
|
+
return { status: 'missing-credentials', backend: 'e2b', error: 'E2B API key is required.' };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await verifyCredentials(credentials);
|
|
25
|
+
return { status: 'ready', backend: 'e2b' };
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return {
|
|
29
|
+
status: 'error',
|
|
30
|
+
backend: 'e2b',
|
|
31
|
+
error: err instanceof Error ? err.message : 'E2B health check failed.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function execute(request, credentials) {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
const apiKey = credentials.apiKey?.trim();
|
|
38
|
+
if (!apiKey)
|
|
39
|
+
return failedResult('E2B API key is required.', startTime);
|
|
40
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
41
|
+
let sandbox;
|
|
42
|
+
try {
|
|
43
|
+
const { Sandbox } = await loadSdk();
|
|
44
|
+
const options = { apiKey, timeoutMs: timeoutMs + 60_000, allowInternetAccess: false };
|
|
45
|
+
const snapshotId = credentials.snapshotId?.trim();
|
|
46
|
+
sandbox = await withTimeout(snapshotId ? Sandbox.create(snapshotId, options) : Sandbox.create(options), timeoutMs + 30_000, 'E2B sandbox create');
|
|
47
|
+
const activeSandbox = sandbox;
|
|
48
|
+
return await runScriptedExecution(async (script) => {
|
|
49
|
+
const result = await activeSandbox.commands.run(script, { timeoutMs });
|
|
50
|
+
return {
|
|
51
|
+
stdout: result.stdout ?? '',
|
|
52
|
+
stderr: result.stderr ?? '',
|
|
53
|
+
exitCode: result.exitCode ?? 0,
|
|
54
|
+
};
|
|
55
|
+
}, request);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return failedResult(err instanceof Error ? err.message : 'E2B execution failed.', startTime);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (sandbox)
|
|
62
|
+
await safeCleanup(() => sandbox.kill());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export const e2bAdapter = {
|
|
66
|
+
id: 'e2b',
|
|
67
|
+
verifyCredentials,
|
|
68
|
+
healthCheck,
|
|
69
|
+
execute,
|
|
70
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Sprites adapter — https://fly.io/sprites/
|
|
3
|
+
*
|
|
4
|
+
* The `@fly/sprites` SDK is very early (0.2.x) and its docs don't confirm a
|
|
5
|
+
* dedicated whoami/list call. `verifyCredentials` falls back to creating and
|
|
6
|
+
* immediately deleting a throwaway sprite if no lighter check is exposed.
|
|
7
|
+
*/
|
|
8
|
+
import type { SandboxProviderAdapter } from '../types.js';
|
|
9
|
+
export declare const flyioAdapter: SandboxProviderAdapter;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Sprites adapter — https://fly.io/sprites/
|
|
3
|
+
*
|
|
4
|
+
* The `@fly/sprites` SDK is very early (0.2.x) and its docs don't confirm a
|
|
5
|
+
* dedicated whoami/list call. `verifyCredentials` falls back to creating and
|
|
6
|
+
* immediately deleting a throwaway sprite if no lighter check is exposed.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { DEFAULT_TIMEOUT_MS, failedResult, runScriptedExecution, safeCleanup, withTimeout, } from '../remote-exec.js';
|
|
10
|
+
async function loadSdk() {
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
return (await import(/* webpackIgnore: true */ '@fly/sprites'));
|
|
13
|
+
}
|
|
14
|
+
function requireToken(credentials) {
|
|
15
|
+
const token = credentials.token?.trim();
|
|
16
|
+
if (!token)
|
|
17
|
+
throw new Error('Fly Sprites token is required.');
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
async function verifyCredentials(credentials) {
|
|
21
|
+
const token = requireToken(credentials);
|
|
22
|
+
const { SpritesClient } = await loadSdk();
|
|
23
|
+
const client = new SpritesClient(token);
|
|
24
|
+
if (typeof client.listSprites === 'function') {
|
|
25
|
+
await client.listSprites();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const sprite = await client.createSprite(`larkup-verify-${randomUUID().slice(0, 8)}`);
|
|
29
|
+
await safeCleanup(() => sprite.delete());
|
|
30
|
+
}
|
|
31
|
+
async function healthCheck(credentials) {
|
|
32
|
+
if (!credentials.token?.trim()) {
|
|
33
|
+
return {
|
|
34
|
+
status: 'missing-credentials',
|
|
35
|
+
backend: 'flyio',
|
|
36
|
+
error: 'Fly Sprites token is required.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await verifyCredentials(credentials);
|
|
41
|
+
return { status: 'ready', backend: 'flyio' };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'error',
|
|
46
|
+
backend: 'flyio',
|
|
47
|
+
error: err instanceof Error ? err.message : 'Fly Sprites health check failed.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function execute(request, credentials) {
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
let token;
|
|
54
|
+
try {
|
|
55
|
+
token = requireToken(credentials);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return failedResult(err instanceof Error ? err.message : 'Missing Fly Sprites token.', startTime);
|
|
59
|
+
}
|
|
60
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
let sprite;
|
|
62
|
+
try {
|
|
63
|
+
const { SpritesClient } = await loadSdk();
|
|
64
|
+
const client = new SpritesClient(token);
|
|
65
|
+
sprite = await withTimeout(client.createSprite(`larkup-${randomUUID().slice(0, 8)}`), timeoutMs + 30_000, 'Fly Sprite create');
|
|
66
|
+
const activeSprite = sprite;
|
|
67
|
+
return await withTimeout(runScriptedExecution(async (script) => {
|
|
68
|
+
try {
|
|
69
|
+
const result = await activeSprite.exec(script);
|
|
70
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: 0 };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const execErr = err;
|
|
74
|
+
if (execErr && typeof execErr.exitCode === 'number') {
|
|
75
|
+
return {
|
|
76
|
+
stdout: execErr.stdout ?? '',
|
|
77
|
+
stderr: execErr.stderr ?? execErr.message,
|
|
78
|
+
exitCode: execErr.exitCode,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}, request), timeoutMs + 15_000, 'Fly Sprite execution');
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return failedResult(err instanceof Error ? err.message : 'Fly Sprites execution failed.', startTime);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (sprite)
|
|
90
|
+
await safeCleanup(() => sprite.delete());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export const flyioAdapter = {
|
|
94
|
+
id: 'flyio',
|
|
95
|
+
verifyCredentials,
|
|
96
|
+
healthCheck,
|
|
97
|
+
execute,
|
|
98
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SandboxBackend, SandboxProviderAdapter } from '../types.js';
|
|
2
|
+
import { e2bAdapter } from './e2b.js';
|
|
3
|
+
import { vercelAdapter } from './vercel.js';
|
|
4
|
+
import { modalAdapter } from './modal.js';
|
|
5
|
+
import { daytonaAdapter } from './daytona.js';
|
|
6
|
+
import { browserbaseAdapter } from './browserbase.js';
|
|
7
|
+
import { flyioAdapter } from './flyio.js';
|
|
8
|
+
import { northflankAdapter } from './northflank.js';
|
|
9
|
+
import { cloudflareAdapter } from './cloudflare.js';
|
|
10
|
+
import { webcontainersAdapter } from './webcontainers.js';
|
|
11
|
+
export declare const SANDBOX_PROVIDER_ADAPTERS: Record<Exclude<SandboxBackend, 'local' | 'docker' | 'custom'>, SandboxProviderAdapter>;
|
|
12
|
+
export declare function getSandboxProviderAdapter(id: SandboxBackend): SandboxProviderAdapter | undefined;
|
|
13
|
+
export { e2bAdapter, vercelAdapter, modalAdapter, daytonaAdapter, browserbaseAdapter, flyioAdapter, northflankAdapter, cloudflareAdapter, webcontainersAdapter, };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { e2bAdapter } from './e2b.js';
|
|
2
|
+
import { vercelAdapter } from './vercel.js';
|
|
3
|
+
import { modalAdapter } from './modal.js';
|
|
4
|
+
import { daytonaAdapter } from './daytona.js';
|
|
5
|
+
import { browserbaseAdapter } from './browserbase.js';
|
|
6
|
+
import { flyioAdapter } from './flyio.js';
|
|
7
|
+
import { northflankAdapter } from './northflank.js';
|
|
8
|
+
import { cloudflareAdapter } from './cloudflare.js';
|
|
9
|
+
import { webcontainersAdapter } from './webcontainers.js';
|
|
10
|
+
export const SANDBOX_PROVIDER_ADAPTERS = {
|
|
11
|
+
e2b: e2bAdapter,
|
|
12
|
+
vercel: vercelAdapter,
|
|
13
|
+
modal: modalAdapter,
|
|
14
|
+
daytona: daytonaAdapter,
|
|
15
|
+
browserbase: browserbaseAdapter,
|
|
16
|
+
flyio: flyioAdapter,
|
|
17
|
+
northflank: northflankAdapter,
|
|
18
|
+
cloudflare: cloudflareAdapter,
|
|
19
|
+
webcontainers: webcontainersAdapter,
|
|
20
|
+
};
|
|
21
|
+
export function getSandboxProviderAdapter(id) {
|
|
22
|
+
if (id === 'local' || id === 'docker' || id === 'custom')
|
|
23
|
+
return undefined;
|
|
24
|
+
return SANDBOX_PROVIDER_ADAPTERS[id];
|
|
25
|
+
}
|
|
26
|
+
export { e2bAdapter, vercelAdapter, modalAdapter, daytonaAdapter, browserbaseAdapter, flyioAdapter, northflankAdapter, cloudflareAdapter, webcontainersAdapter, };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal adapter — https://modal.com/docs/guide/sandboxes
|
|
3
|
+
*
|
|
4
|
+
* The Modal JavaScript SDK is client-based: create a ModalClient with the
|
|
5
|
+
* configured credentials, then create sandboxes through `modal.sandboxes`.
|
|
6
|
+
*/
|
|
7
|
+
import type { SandboxProviderAdapter } from '../types.js';
|
|
8
|
+
export declare const modalAdapter: SandboxProviderAdapter;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal adapter — https://modal.com/docs/guide/sandboxes
|
|
3
|
+
*
|
|
4
|
+
* The Modal JavaScript SDK is client-based: create a ModalClient with the
|
|
5
|
+
* configured credentials, then create sandboxes through `modal.sandboxes`.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_TIMEOUT_MS, failedResult, runScriptedExecution, safeCleanup, withTimeout, } from '../remote-exec.js';
|
|
8
|
+
async function loadSdk() {
|
|
9
|
+
return (await import('modal'));
|
|
10
|
+
}
|
|
11
|
+
function requireFields(credentials) {
|
|
12
|
+
const tokenId = credentials.tokenId?.trim();
|
|
13
|
+
const tokenSecret = credentials.tokenSecret?.trim();
|
|
14
|
+
if (!tokenId || !tokenSecret)
|
|
15
|
+
throw new Error('Modal token ID and token secret are both required.');
|
|
16
|
+
return { tokenId, tokenSecret };
|
|
17
|
+
}
|
|
18
|
+
async function createClient(tokenId, tokenSecret) {
|
|
19
|
+
const { ModalClient } = await loadSdk();
|
|
20
|
+
return new ModalClient({ tokenId, tokenSecret });
|
|
21
|
+
}
|
|
22
|
+
async function verifyCredentials(credentials) {
|
|
23
|
+
const { tokenId, tokenSecret } = requireFields(credentials);
|
|
24
|
+
const modal = await createClient(tokenId, tokenSecret);
|
|
25
|
+
try {
|
|
26
|
+
await withTimeout(modal.getImageBuilderVersion(), 20_000, 'Modal credential check');
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
modal.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function healthCheck(credentials) {
|
|
33
|
+
if (!credentials.tokenId?.trim() || !credentials.tokenSecret?.trim()) {
|
|
34
|
+
return {
|
|
35
|
+
status: 'missing-credentials',
|
|
36
|
+
backend: 'modal',
|
|
37
|
+
error: 'Modal token ID and secret are required.',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await verifyCredentials(credentials);
|
|
42
|
+
return { status: 'ready', backend: 'modal' };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'error',
|
|
47
|
+
backend: 'modal',
|
|
48
|
+
error: err instanceof Error ? err.message : 'Modal health check failed.',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function execute(request, credentials) {
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
let fields;
|
|
55
|
+
try {
|
|
56
|
+
fields = requireFields(credentials);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return failedResult(err instanceof Error ? err.message : 'Missing Modal credentials.', startTime);
|
|
60
|
+
}
|
|
61
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
62
|
+
const modal = await createClient(fields.tokenId, fields.tokenSecret);
|
|
63
|
+
let sandbox;
|
|
64
|
+
try {
|
|
65
|
+
const app = await withTimeout(modal.apps.fromName('larkup-sandbox', { createIfMissing: true }), timeoutMs + 15_000, 'Modal app setup');
|
|
66
|
+
const image = modal.images.fromRegistry(request.language === 'python' ? 'python:3.13-slim' : 'node:22-bookworm-slim');
|
|
67
|
+
sandbox = await withTimeout(modal.sandboxes.create(app, image, {
|
|
68
|
+
timeoutMs: timeoutMs + 60_000,
|
|
69
|
+
idleTimeoutMs: timeoutMs + 30_000,
|
|
70
|
+
}), timeoutMs + 30_000, 'Modal sandbox create');
|
|
71
|
+
const activeSandbox = sandbox;
|
|
72
|
+
return await withTimeout(runScriptedExecution(async (script) => {
|
|
73
|
+
const process = await activeSandbox.exec(['bash', '-lc', script], { timeoutMs });
|
|
74
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
75
|
+
process.stdout.readText(),
|
|
76
|
+
process.stderr.readText(),
|
|
77
|
+
process.wait(),
|
|
78
|
+
]);
|
|
79
|
+
return { stdout, stderr, exitCode };
|
|
80
|
+
}, request), timeoutMs + 15_000, 'Modal execution');
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return failedResult(err instanceof Error ? err.message : 'Modal execution failed.', startTime);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
if (sandbox)
|
|
87
|
+
await safeCleanup(() => sandbox.terminate());
|
|
88
|
+
modal.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export const modalAdapter = {
|
|
92
|
+
id: 'modal',
|
|
93
|
+
verifyCredentials,
|
|
94
|
+
healthCheck,
|
|
95
|
+
execute,
|
|
96
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
import type { SandboxProviderAdapter } from '../types.js';
|
|
9
|
+
export declare const northflankAdapter: SandboxProviderAdapter;
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { DEFAULT_TIMEOUT_MS, failedResult, runScriptedExecution, safeCleanup, withTimeout, } from '../remote-exec.js';
|
|
10
|
+
async function loadSdk() {
|
|
11
|
+
return (await import('@northflank/js-client'));
|
|
12
|
+
}
|
|
13
|
+
function requireFields(credentials) {
|
|
14
|
+
const token = credentials.token?.trim();
|
|
15
|
+
const projectId = credentials.projectId?.trim();
|
|
16
|
+
if (!token || !projectId)
|
|
17
|
+
throw new Error('Northflank API token and project ID are both required.');
|
|
18
|
+
return { token, projectId };
|
|
19
|
+
}
|
|
20
|
+
async function buildClient(token) {
|
|
21
|
+
const { ApiClient, ApiClientInMemoryContextProvider } = await loadSdk();
|
|
22
|
+
const ctx = new ApiClientInMemoryContextProvider();
|
|
23
|
+
await ctx.addContext({ name: 'larkup', token });
|
|
24
|
+
return new ApiClient(ctx, { throwErrorOnHttpErrorCode: true });
|
|
25
|
+
}
|
|
26
|
+
async function verifyCredentials(credentials) {
|
|
27
|
+
const { token, projectId } = requireFields(credentials);
|
|
28
|
+
const client = await buildClient(token);
|
|
29
|
+
await withTimeout(client.get.project({ parameters: { projectId } }), 20_000, 'Northflank credential check');
|
|
30
|
+
}
|
|
31
|
+
async function healthCheck(credentials) {
|
|
32
|
+
if (!credentials.token?.trim() || !credentials.projectId?.trim()) {
|
|
33
|
+
return {
|
|
34
|
+
status: 'missing-credentials',
|
|
35
|
+
backend: 'northflank',
|
|
36
|
+
error: 'Northflank API token and project ID are required.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await verifyCredentials(credentials);
|
|
41
|
+
return { status: 'ready', backend: 'northflank' };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'error',
|
|
46
|
+
backend: 'northflank',
|
|
47
|
+
error: err instanceof Error ? err.message : 'Northflank health check failed.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function waitForServiceReady(client, projectId, serviceId, timeoutMs) {
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
const service = await client.get.service({ parameters: { projectId, serviceId } });
|
|
55
|
+
const status = service.data?.status;
|
|
56
|
+
if (status === 'COMPLETED' || status === undefined)
|
|
57
|
+
return; // running, or status field not modeled — proceed
|
|
58
|
+
if (status === 'FAILED')
|
|
59
|
+
throw new Error('Northflank sandbox service failed to start.');
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
61
|
+
}
|
|
62
|
+
throw new Error('Timed out waiting for the Northflank sandbox service to become ready.');
|
|
63
|
+
}
|
|
64
|
+
async function execute(request, credentials) {
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
let fields;
|
|
67
|
+
try {
|
|
68
|
+
fields = requireFields(credentials);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return failedResult(err instanceof Error ? err.message : 'Missing Northflank credentials.', startTime);
|
|
72
|
+
}
|
|
73
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
74
|
+
const serviceId = `larkup-${randomUUID().slice(0, 8)}`;
|
|
75
|
+
const { projectId } = fields;
|
|
76
|
+
let client;
|
|
77
|
+
try {
|
|
78
|
+
client = await buildClient(fields.token);
|
|
79
|
+
await withTimeout(client.create.service.deployment({
|
|
80
|
+
parameters: { projectId },
|
|
81
|
+
data: {
|
|
82
|
+
name: serviceId,
|
|
83
|
+
billing: { deploymentPlan: 'nf-compute-200' },
|
|
84
|
+
deployment: {
|
|
85
|
+
instances: 1,
|
|
86
|
+
docker: { configType: 'customCommand', customCommand: 'sleep infinity' },
|
|
87
|
+
external: { imagePath: 'ubuntu:22.04' },
|
|
88
|
+
storage: { ephemeralStorage: { storageSize: 2048 } },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}), 30_000, 'Northflank service create');
|
|
92
|
+
await waitForServiceReady(client, projectId, serviceId, 60_000);
|
|
93
|
+
return await withTimeout(runScriptedExecution(async (script) => {
|
|
94
|
+
const activeClient = client;
|
|
95
|
+
const handle = await activeClient.exec.execServiceSession({ projectId, serviceId }, { shell: 'bash -c', command: script });
|
|
96
|
+
const stdoutChunks = [];
|
|
97
|
+
const stderrChunks = [];
|
|
98
|
+
handle.stdOut.on('data', (chunk) => stdoutChunks.push(chunk.toString()));
|
|
99
|
+
handle.stdErr.on('data', (chunk) => stderrChunks.push(chunk.toString()));
|
|
100
|
+
const result = await handle.waitForCommandResult();
|
|
101
|
+
return {
|
|
102
|
+
stdout: stdoutChunks.join(''),
|
|
103
|
+
stderr: stderrChunks.join(''),
|
|
104
|
+
exitCode: result.exitCode ?? 0,
|
|
105
|
+
};
|
|
106
|
+
}, request), timeoutMs + 15_000, 'Northflank execution');
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
return failedResult(err instanceof Error ? err.message : 'Northflank execution failed.', startTime);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (client) {
|
|
113
|
+
await safeCleanup(() => client.delete.service({ parameters: { projectId, serviceId } }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export const northflankAdapter = {
|
|
118
|
+
id: 'northflank',
|
|
119
|
+
verifyCredentials,
|
|
120
|
+
healthCheck,
|
|
121
|
+
execute,
|
|
122
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
import type { SandboxProviderAdapter } from '../types.js';
|
|
9
|
+
export declare const vercelAdapter: SandboxProviderAdapter;
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
import { DEFAULT_TIMEOUT_MS, failedResult, runScriptedExecution, safeCleanup, withTimeout, } from '../remote-exec.js';
|
|
9
|
+
async function loadSdk() {
|
|
10
|
+
return (await import('@vercel/sandbox'));
|
|
11
|
+
}
|
|
12
|
+
function requireFields(credentials) {
|
|
13
|
+
const token = credentials.token?.trim();
|
|
14
|
+
const teamId = credentials.teamId?.trim();
|
|
15
|
+
const projectId = credentials.projectId?.trim();
|
|
16
|
+
if (!token || !teamId || !projectId) {
|
|
17
|
+
throw new Error('Vercel access token, team ID, and project ID are all required.');
|
|
18
|
+
}
|
|
19
|
+
return { token, teamId, projectId };
|
|
20
|
+
}
|
|
21
|
+
async function verifyCredentials(credentials) {
|
|
22
|
+
const { token, teamId, projectId } = requireFields(credentials);
|
|
23
|
+
const { Sandbox } = await loadSdk();
|
|
24
|
+
await (await Sandbox.list({ token, teamId, projectId })).toArray();
|
|
25
|
+
}
|
|
26
|
+
async function healthCheck(credentials) {
|
|
27
|
+
try {
|
|
28
|
+
await verifyCredentials(credentials);
|
|
29
|
+
return { status: 'ready', backend: 'vercel' };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const missing = !credentials.token || !credentials.teamId || !credentials.projectId;
|
|
33
|
+
return {
|
|
34
|
+
status: missing ? 'missing-credentials' : 'error',
|
|
35
|
+
backend: 'vercel',
|
|
36
|
+
error: err instanceof Error ? err.message : 'Vercel Sandbox health check failed.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function execute(request, credentials) {
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
let fields;
|
|
43
|
+
try {
|
|
44
|
+
fields = requireFields(credentials);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return failedResult(err instanceof Error ? err.message : 'Missing Vercel credentials.', startTime);
|
|
48
|
+
}
|
|
49
|
+
const timeoutMs = request.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
50
|
+
let sandbox;
|
|
51
|
+
try {
|
|
52
|
+
const { Sandbox } = await loadSdk();
|
|
53
|
+
const snapshotId = credentials.snapshotId?.trim();
|
|
54
|
+
sandbox = await withTimeout(Sandbox.create({
|
|
55
|
+
...fields,
|
|
56
|
+
timeout: timeoutMs + 60_000,
|
|
57
|
+
...(snapshotId ? { source: { type: 'snapshot', snapshotId } } : {}),
|
|
58
|
+
}), timeoutMs + 30_000, 'Vercel sandbox create');
|
|
59
|
+
const activeSandbox = sandbox;
|
|
60
|
+
return await withTimeout(runScriptedExecution(async (script) => {
|
|
61
|
+
const result = await activeSandbox.runCommand('bash', ['-c', script]);
|
|
62
|
+
const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]);
|
|
63
|
+
return { stdout, stderr, exitCode: result.exitCode };
|
|
64
|
+
}, request), timeoutMs + 15_000, 'Vercel Sandbox execution');
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return failedResult(err instanceof Error ? err.message : 'Vercel Sandbox execution failed.', startTime);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (sandbox)
|
|
71
|
+
await safeCleanup(() => sandbox.stop());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export const vercelAdapter = {
|
|
75
|
+
id: 'vercel',
|
|
76
|
+
verifyCredentials,
|
|
77
|
+
healthCheck,
|
|
78
|
+
execute,
|
|
79
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
import type { SandboxProviderAdapter } from '../types.js';
|
|
13
|
+
export declare const webcontainersAdapter: SandboxProviderAdapter;
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
import { failedResult } from '../remote-exec.js';
|
|
13
|
+
import { SANDBOX_PROVIDERS } from '../registry.js';
|
|
14
|
+
async function verifyCredentials(_credentials) {
|
|
15
|
+
throw new Error('WebContainers has no server-side credential check — the license key is only validated by StackBlitz when WebContainer.boot() runs in the browser.');
|
|
16
|
+
}
|
|
17
|
+
async function healthCheck(_credentials) {
|
|
18
|
+
return { status: 'unsupported', backend: 'webcontainers', error: SANDBOX_PROVIDERS.webcontainers.executionCaveat };
|
|
19
|
+
}
|
|
20
|
+
async function execute(_request, _credentials) {
|
|
21
|
+
return failedResult(SANDBOX_PROVIDERS.webcontainers.executionCaveat, Date.now());
|
|
22
|
+
}
|
|
23
|
+
export const webcontainersAdapter = {
|
|
24
|
+
id: 'webcontainers',
|
|
25
|
+
verifyCredentials,
|
|
26
|
+
healthCheck,
|
|
27
|
+
execute,
|
|
28
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SandboxBackend, SandboxProviderDescriptor } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Declarative registry of the remote sandbox providers @larkup/sandbox can
|
|
4
|
+
* execute code on, besides the always-available local Docker backend.
|
|
5
|
+
*
|
|
6
|
+
* This single object powers two consumers:
|
|
7
|
+
* 1. The Settings UI — renders `fields` dynamically so each provider asks
|
|
8
|
+
* for exactly the credentials it needs, the same way vector store and
|
|
9
|
+
* embedding provider fields are rendered from their own registries.
|
|
10
|
+
* 2. `SandboxManager` / the provider adapters — read `id` to route
|
|
11
|
+
* execution and `executionSupport` to fail fast with a clear message
|
|
12
|
+
* instead of pretending every provider can run arbitrary code.
|
|
13
|
+
*
|
|
14
|
+
* Adding a new provider = one entry here + one adapter in `./providers`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SANDBOX_PROVIDERS: Record<Exclude<SandboxBackend, 'local' | 'docker' | 'custom'>, SandboxProviderDescriptor>;
|
|
17
|
+
export declare const SANDBOX_PROVIDER_LIST: SandboxProviderDescriptor[];
|
|
18
|
+
export declare function getSandboxProvider(id: SandboxBackend): SandboxProviderDescriptor | undefined;
|
|
19
|
+
/** Validates credentials against a provider's registered fields, mirroring validateStoreConfig. */
|
|
20
|
+
export declare function validateSandboxCredentials(provider: SandboxProviderDescriptor, values: Record<string, string>): Record<string, string>;
|