@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
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SandboxBackend =
|
|
1
|
+
export type SandboxBackend = 'local' | 'docker' | 'e2b' | 'vercel' | 'modal' | 'daytona' | 'browserbase' | 'flyio' | 'northflank' | 'cloudflare' | 'webcontainers' | 'custom';
|
|
2
2
|
export interface DockerConfig {
|
|
3
3
|
/** Docker image to use (default: "larkup-sandbox:latest") */
|
|
4
4
|
imageName: string;
|
|
@@ -11,18 +11,18 @@ export interface DockerConfig {
|
|
|
11
11
|
/** Disable network access in the container (default: true) */
|
|
12
12
|
networkDisabled: boolean;
|
|
13
13
|
}
|
|
14
|
-
export interface RemoteConfig {
|
|
15
|
-
provider: "e2b" | "modal" | "custom";
|
|
16
|
-
apiKey: string;
|
|
17
|
-
endpoint?: string;
|
|
18
|
-
}
|
|
19
14
|
export interface SandboxConfig {
|
|
20
15
|
backend: SandboxBackend;
|
|
21
16
|
docker?: Partial<DockerConfig>;
|
|
22
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Credentials for the selected remote provider, keyed by the field `key`s
|
|
19
|
+
* declared in that provider's `SandboxProviderDescriptor` (see registry.ts).
|
|
20
|
+
* Unused by local and Docker backends.
|
|
21
|
+
*/
|
|
22
|
+
credentials?: Record<string, string>;
|
|
23
23
|
}
|
|
24
24
|
export declare const DEFAULT_DOCKER_CONFIG: DockerConfig;
|
|
25
|
-
export type SandboxLanguage =
|
|
25
|
+
export type SandboxLanguage = 'python' | 'javascript' | 'typescript';
|
|
26
26
|
export interface SandboxFile {
|
|
27
27
|
/** Filename inside the sandbox (e.g., "data.csv") */
|
|
28
28
|
name: string;
|
|
@@ -61,7 +61,7 @@ export interface ExecutionResult {
|
|
|
61
61
|
/** Wall-clock execution time in ms */
|
|
62
62
|
executionTimeMs: number;
|
|
63
63
|
}
|
|
64
|
-
export type SandboxStatus =
|
|
64
|
+
export type SandboxStatus = 'ready' | 'docker-not-found' | 'image-not-found' | 'building-image' | 'missing-credentials' | 'unsupported' | 'error';
|
|
65
65
|
export interface SandboxHealthCheck {
|
|
66
66
|
status: SandboxStatus;
|
|
67
67
|
backend: SandboxBackend;
|
|
@@ -69,3 +69,52 @@ export interface SandboxHealthCheck {
|
|
|
69
69
|
imageReady?: boolean;
|
|
70
70
|
error?: string;
|
|
71
71
|
}
|
|
72
|
+
export type SandboxFieldType = 'text' | 'password';
|
|
73
|
+
/** A single credential/config field a sandbox provider needs. The Settings
|
|
74
|
+
* UI renders these dynamically per provider, the same way vector store and
|
|
75
|
+
* embedding provider fields are rendered from their own registries. */
|
|
76
|
+
export interface SandboxCredentialField {
|
|
77
|
+
key: string;
|
|
78
|
+
label: string;
|
|
79
|
+
type: SandboxFieldType;
|
|
80
|
+
required: boolean;
|
|
81
|
+
placeholder?: string;
|
|
82
|
+
help?: string;
|
|
83
|
+
secret?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface SandboxProviderDescriptor {
|
|
86
|
+
id: SandboxBackend;
|
|
87
|
+
label: string;
|
|
88
|
+
description: string;
|
|
89
|
+
/** npm package this adapter dynamically imports at runtime. Never a hard
|
|
90
|
+
* dependency of @larkup/sandbox — installing all nine SDKs unconditionally
|
|
91
|
+
* would bloat every consumer, so each adapter lazy-imports its own. */
|
|
92
|
+
sdkPackage?: string;
|
|
93
|
+
docsUrl: string;
|
|
94
|
+
/** Path under /public/icons (or /public) in the web app. */
|
|
95
|
+
icon: string;
|
|
96
|
+
/**
|
|
97
|
+
* Whether `execute()` genuinely runs arbitrary code/commands and returns
|
|
98
|
+
* output, or the provider is architecturally something else (browser
|
|
99
|
+
* automation, a client-only runtime, a Worker binding). Surfaced in the
|
|
100
|
+
* UI so operators don't pick a provider that can't do what they expect.
|
|
101
|
+
*/
|
|
102
|
+
executionSupport: 'full' | 'unsupported';
|
|
103
|
+
/** Shown in the UI when executionSupport !== "full". */
|
|
104
|
+
executionCaveat?: string;
|
|
105
|
+
fields: SandboxCredentialField[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Adapter contract every remote sandbox backend implements. Docker is
|
|
109
|
+
* handled separately (docker-runner.ts) since it talks to the local daemon
|
|
110
|
+
* and needs no credentials.
|
|
111
|
+
*/
|
|
112
|
+
export interface SandboxProviderAdapter {
|
|
113
|
+
readonly id: SandboxBackend;
|
|
114
|
+
/** Cheap call that proves the credentials are valid — powers the "Verify" button. */
|
|
115
|
+
verifyCredentials(credentials: Record<string, string>): Promise<void>;
|
|
116
|
+
/** Confirms the backend is reachable/configured without spinning up a full sandbox. */
|
|
117
|
+
healthCheck(credentials: Record<string, string>): Promise<SandboxHealthCheck>;
|
|
118
|
+
/** Runs code/commands inside a fresh sandbox instance and tears it down afterward. */
|
|
119
|
+
execute(request: ExecutionRequest, credentials: Record<string, string>): Promise<ExecutionResult>;
|
|
120
|
+
}
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@larkup/sandbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -27,17 +27,34 @@
|
|
|
27
27
|
"types": "./dist/sandbox-manager.d.ts",
|
|
28
28
|
"import": "./dist/sandbox-manager.js",
|
|
29
29
|
"default": "./dist/sandbox-manager.js"
|
|
30
|
+
},
|
|
31
|
+
"./registry": {
|
|
32
|
+
"types": "./dist/registry.d.ts",
|
|
33
|
+
"import": "./dist/registry.js",
|
|
34
|
+
"default": "./dist/registry.js"
|
|
30
35
|
}
|
|
31
36
|
},
|
|
32
37
|
"dependencies": {
|
|
33
|
-
"dockerode": "^
|
|
38
|
+
"dockerode": "^5.0.1"
|
|
39
|
+
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"e2b": "^2.39.0",
|
|
42
|
+
"@vercel/sandbox": "^3.0.0",
|
|
43
|
+
"modal": "^0.9.0",
|
|
44
|
+
"@daytonaio/sdk": "^0.204.1",
|
|
45
|
+
"@browserbasehq/sdk": "^2.17.0",
|
|
46
|
+
"playwright-core": "^1.62.1",
|
|
47
|
+
"@fly/sprites": "^0.2.0",
|
|
48
|
+
"@northflank/js-client": "^0.10.1"
|
|
34
49
|
},
|
|
35
50
|
"devDependencies": {
|
|
36
|
-
"@types/dockerode": "^
|
|
37
|
-
"typescript": "
|
|
51
|
+
"@types/dockerode": "^4.0.1",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
|
+
"vitest": "^4.1.10"
|
|
38
54
|
},
|
|
39
55
|
"scripts": {
|
|
40
|
-
"build": "tsc --outDir dist",
|
|
41
|
-
"type-check": "tsc --noEmit"
|
|
56
|
+
"build": "tsc -p tsconfig.build.json --outDir dist",
|
|
57
|
+
"type-check": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run"
|
|
42
59
|
}
|
|
43
60
|
}
|
package/src/docker-runner.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* generated files, then auto-removes the container.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import Docker from 'dockerode';
|
|
10
9
|
import { promises as fs } from 'node:fs';
|
|
11
10
|
import path from 'node:path';
|
|
12
11
|
import os from 'node:os';
|
|
@@ -25,21 +24,20 @@ const OUTPUT_DIR = '/sandbox/output';
|
|
|
25
24
|
const INPUT_DIR = '/sandbox/input';
|
|
26
25
|
const DOCKERFILE_DIR = path.join(import.meta.dirname ?? __dirname, '..');
|
|
27
26
|
|
|
28
|
-
let dockerInstance:
|
|
27
|
+
let dockerInstance: any | null = null;
|
|
29
28
|
|
|
30
|
-
function getDocker():
|
|
29
|
+
async function getDocker(): Promise<any> {
|
|
31
30
|
if (!dockerInstance) {
|
|
31
|
+
// Dynamic import prevents Turbopack from statically bundling dockerode
|
|
32
|
+
// and mangling its module name with a hash suffix.
|
|
33
|
+
const { default: Docker } = await import(/* turbopackIgnore: true */ 'dockerode');
|
|
32
34
|
dockerInstance = new Docker();
|
|
33
35
|
}
|
|
34
36
|
return dockerInstance;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/* ------------------------------------------------------------------ */
|
|
38
|
-
/* Health check */
|
|
39
|
-
/* ------------------------------------------------------------------ */
|
|
40
|
-
|
|
41
39
|
export async function checkDockerHealth(): Promise<SandboxHealthCheck> {
|
|
42
|
-
const docker = getDocker();
|
|
40
|
+
const docker = await getDocker();
|
|
43
41
|
try {
|
|
44
42
|
const info = await docker.version();
|
|
45
43
|
const images = await docker.listImages({
|
|
@@ -52,20 +50,21 @@ export async function checkDockerHealth(): Promise<SandboxHealthCheck> {
|
|
|
52
50
|
imageReady: images.length > 0,
|
|
53
51
|
};
|
|
54
52
|
} catch (err: any) {
|
|
53
|
+
const raw = err?.message ?? '';
|
|
54
|
+
const dockerMissing =
|
|
55
|
+
/ENOENT|ECONNREFUSED|EACCES|no such file|cannot connect to the docker daemon/i.test(raw);
|
|
55
56
|
return {
|
|
56
57
|
status: 'docker-not-found',
|
|
57
58
|
backend: 'docker',
|
|
58
|
-
error:
|
|
59
|
+
error: dockerMissing
|
|
60
|
+
? "Docker isn't installed or running on this machine. Local code execution is optional — install Docker Desktop to enable it, or configure a remote sandbox provider below."
|
|
61
|
+
: raw || 'Docker daemon not available',
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
/* ------------------------------------------------------------------ */
|
|
64
|
-
/* Image management */
|
|
65
|
-
/* ------------------------------------------------------------------ */
|
|
66
|
-
|
|
67
66
|
export async function buildSandboxImage(onProgress?: (msg: string) => void): Promise<void> {
|
|
68
|
-
const docker = getDocker();
|
|
67
|
+
const docker = await getDocker();
|
|
69
68
|
const dockerfilePath = path.join(DOCKERFILE_DIR, 'Dockerfile');
|
|
70
69
|
|
|
71
70
|
// Check if Dockerfile exists in the package
|
|
@@ -105,7 +104,7 @@ export async function buildSandboxImage(onProgress?: (msg: string) => void): Pro
|
|
|
105
104
|
}
|
|
106
105
|
|
|
107
106
|
export async function ensureImage(): Promise<boolean> {
|
|
108
|
-
const docker = getDocker();
|
|
107
|
+
const docker = await getDocker();
|
|
109
108
|
const images = await docker.listImages({
|
|
110
109
|
filters: { reference: [SANDBOX_IMAGE] },
|
|
111
110
|
});
|
|
@@ -119,10 +118,6 @@ export async function ensureImage(): Promise<boolean> {
|
|
|
119
118
|
}
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
/* ------------------------------------------------------------------ */
|
|
123
|
-
/* Code execution */
|
|
124
|
-
/* ------------------------------------------------------------------ */
|
|
125
|
-
|
|
126
121
|
/**
|
|
127
122
|
* Build the Python wrapper script that:
|
|
128
123
|
* 1. Sets up pandas compatibility shims to handle deprecated APIs
|
|
@@ -279,7 +274,7 @@ export async function executeInDocker(
|
|
|
279
274
|
request: ExecutionRequest,
|
|
280
275
|
config: DockerConfig,
|
|
281
276
|
): Promise<ExecutionResult> {
|
|
282
|
-
const docker = getDocker();
|
|
277
|
+
const docker = await getDocker();
|
|
283
278
|
const startTime = Date.now();
|
|
284
279
|
|
|
285
280
|
// Create a temporary directory for input files
|
|
@@ -292,6 +287,22 @@ export async function executeInDocker(
|
|
|
292
287
|
// Write input files
|
|
293
288
|
if (request.files) {
|
|
294
289
|
for (const file of request.files) {
|
|
290
|
+
if (
|
|
291
|
+
!file.name ||
|
|
292
|
+
file.name === '.' ||
|
|
293
|
+
file.name === '..' ||
|
|
294
|
+
file.name.includes('/') ||
|
|
295
|
+
file.name.includes('\\') ||
|
|
296
|
+
file.name.includes('\0')
|
|
297
|
+
) {
|
|
298
|
+
return {
|
|
299
|
+
stdout: '',
|
|
300
|
+
stderr: 'Sandbox file names must be plain file names.',
|
|
301
|
+
exitCode: 1,
|
|
302
|
+
artifacts: [],
|
|
303
|
+
executionTimeMs: Date.now() - startTime,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
295
306
|
const filePath = path.join(inputDir, file.name);
|
|
296
307
|
if (file.isBase64) {
|
|
297
308
|
await fs.writeFile(filePath, Buffer.from(file.content, 'base64'));
|
|
@@ -304,22 +315,25 @@ export async function executeInDocker(
|
|
|
304
315
|
// Prepare the execution code
|
|
305
316
|
let execCode: string;
|
|
306
317
|
let cmd: string[];
|
|
318
|
+
let scriptName: string;
|
|
307
319
|
|
|
308
320
|
if (request.language === 'python') {
|
|
309
321
|
execCode = wrapPythonCode(request.code);
|
|
310
322
|
const scriptPath = path.join(tmpDir, 'run.py');
|
|
311
323
|
await fs.writeFile(scriptPath, execCode, 'utf8');
|
|
324
|
+
scriptName = 'run.py';
|
|
312
325
|
cmd = ['python3', '/sandbox/run.py'];
|
|
313
326
|
} else {
|
|
314
327
|
// JavaScript/TypeScript (future)
|
|
315
328
|
const scriptPath = path.join(tmpDir, 'run.js');
|
|
316
329
|
await fs.writeFile(scriptPath, request.code, 'utf8');
|
|
330
|
+
scriptName = 'run.js';
|
|
317
331
|
cmd = ['node', '/sandbox/run.js'];
|
|
318
332
|
}
|
|
319
333
|
|
|
320
334
|
const timeout = request.timeout ?? config.timeoutMs;
|
|
321
335
|
|
|
322
|
-
let container:
|
|
336
|
+
let container: any | null = null;
|
|
323
337
|
try {
|
|
324
338
|
container = await docker.createContainer({
|
|
325
339
|
Image: config.imageName,
|
|
@@ -334,7 +348,7 @@ export async function executeInDocker(
|
|
|
334
348
|
Binds: [
|
|
335
349
|
`${inputDir}:${INPUT_DIR}:ro`,
|
|
336
350
|
`${outputDir}:${OUTPUT_DIR}:rw`,
|
|
337
|
-
`${tmpDir}
|
|
351
|
+
`${tmpDir}/${scriptName}:/sandbox/${scriptName}:ro`,
|
|
338
352
|
],
|
|
339
353
|
ReadonlyRootfs: false,
|
|
340
354
|
},
|
|
@@ -347,8 +361,9 @@ export async function executeInDocker(
|
|
|
347
361
|
|
|
348
362
|
// Wait for completion with timeout
|
|
349
363
|
const waitPromise = container.wait();
|
|
364
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
350
365
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
351
|
-
setTimeout(async () => {
|
|
366
|
+
timeoutHandle = setTimeout(async () => {
|
|
352
367
|
try {
|
|
353
368
|
await container?.kill();
|
|
354
369
|
} catch {
|
|
@@ -359,6 +374,7 @@ export async function executeInDocker(
|
|
|
359
374
|
});
|
|
360
375
|
|
|
361
376
|
const result = await Promise.race([waitPromise, timeoutPromise]);
|
|
377
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
362
378
|
|
|
363
379
|
// Capture logs
|
|
364
380
|
const logs = await container.logs({
|
|
@@ -423,10 +439,6 @@ export async function executeInDocker(
|
|
|
423
439
|
}
|
|
424
440
|
}
|
|
425
441
|
|
|
426
|
-
/* ------------------------------------------------------------------ */
|
|
427
|
-
/* Helpers */
|
|
428
|
-
/* ------------------------------------------------------------------ */
|
|
429
|
-
|
|
430
442
|
/**
|
|
431
443
|
* Docker multiplexes stdout/stderr into a single stream with 8-byte headers.
|
|
432
444
|
* Header format: [type(1)][0(3)][size(4)] where type: 1=stdout, 2=stderr
|
package/src/index.ts
CHANGED
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
* Re-exports the public API for the sandbox package.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export { SandboxManager } from './sandbox-manager.js';
|
|
7
|
+
export { SandboxManager, verifySandboxProvider } from './sandbox-manager.js';
|
|
8
8
|
export {
|
|
9
9
|
checkDockerHealth,
|
|
10
10
|
buildSandboxImage,
|
|
11
11
|
ensureImage,
|
|
12
12
|
executeInDocker,
|
|
13
13
|
} from './docker-runner.js';
|
|
14
|
+
export { checkLocalRuntime, executeLocally } from './local-runner.js';
|
|
15
|
+
export {
|
|
16
|
+
SANDBOX_PROVIDERS,
|
|
17
|
+
SANDBOX_PROVIDER_LIST,
|
|
18
|
+
getSandboxProvider,
|
|
19
|
+
validateSandboxCredentials,
|
|
20
|
+
} from './registry.js';
|
|
14
21
|
export type {
|
|
15
22
|
SandboxBackend,
|
|
16
23
|
SandboxConfig,
|
|
17
24
|
DockerConfig,
|
|
18
|
-
RemoteConfig,
|
|
19
25
|
SandboxLanguage,
|
|
20
26
|
SandboxFile,
|
|
21
27
|
ExecutionRequest,
|
|
@@ -23,4 +29,7 @@ export type {
|
|
|
23
29
|
ExecutionArtifact,
|
|
24
30
|
SandboxStatus,
|
|
25
31
|
SandboxHealthCheck,
|
|
32
|
+
SandboxCredentialField,
|
|
33
|
+
SandboxProviderDescriptor,
|
|
34
|
+
SandboxProviderAdapter,
|
|
26
35
|
} from './types.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { executeLocally } from './local-runner.js';
|
|
3
|
+
|
|
4
|
+
describe('executeLocally', () => {
|
|
5
|
+
it('runs JavaScript without Docker and returns output artifacts', async () => {
|
|
6
|
+
const result = await executeLocally({
|
|
7
|
+
language: 'javascript',
|
|
8
|
+
code: `
|
|
9
|
+
const fs = require('node:fs/promises');
|
|
10
|
+
void (async () => {
|
|
11
|
+
await fs.writeFile(process.env.LARKUP_OUTPUT_DIR + '/result.txt', 'done');
|
|
12
|
+
console.log('local runtime');
|
|
13
|
+
})();
|
|
14
|
+
`,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(result).toMatchObject({ exitCode: 0, stdout: expect.stringContaining('local runtime') });
|
|
18
|
+
expect(result.artifacts).toEqual([
|
|
19
|
+
expect.objectContaining({ name: 'result.txt', mimeType: 'text/plain' }),
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import type { EventEmitter } from 'node:events';
|
|
7
|
+
import type {
|
|
8
|
+
ExecutionArtifact,
|
|
9
|
+
ExecutionRequest,
|
|
10
|
+
ExecutionResult,
|
|
11
|
+
SandboxHealthCheck,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import { getMimeType } from './mime.js';
|
|
14
|
+
|
|
15
|
+
type Command = { executable: string; args: string[] };
|
|
16
|
+
|
|
17
|
+
let pythonCommand: Command | null | undefined;
|
|
18
|
+
|
|
19
|
+
function run(command: Command, args: string[], cwd?: string, timeoutMs = 10_000) {
|
|
20
|
+
return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
|
|
21
|
+
const child = spawn(command.executable, [...command.args, ...args], {
|
|
22
|
+
cwd,
|
|
23
|
+
env: process.env,
|
|
24
|
+
shell: false,
|
|
25
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
});
|
|
27
|
+
// Node ChildProcess is an EventEmitter at runtime. TypeScript 7's narrowed stdio return type
|
|
28
|
+
// omits that inherited surface, so retain it explicitly for lifecycle events.
|
|
29
|
+
const lifecycle = child as unknown as EventEmitter;
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
let timedOut = false;
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
timedOut = true;
|
|
35
|
+
child.kill('SIGKILL');
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
child.stdout.on('data', (chunk) => {
|
|
38
|
+
stdout = (stdout + chunk).slice(-1_000_000);
|
|
39
|
+
});
|
|
40
|
+
child.stderr.on('data', (chunk) => {
|
|
41
|
+
stderr = (stderr + chunk).slice(-1_000_000);
|
|
42
|
+
});
|
|
43
|
+
lifecycle.once('error', (error: Error) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(error);
|
|
46
|
+
});
|
|
47
|
+
lifecycle.once('close', (code: number | null) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolve({
|
|
50
|
+
stdout,
|
|
51
|
+
stderr: timedOut ? `${stderr}\nExecution timed out.`.trim() : stderr,
|
|
52
|
+
exitCode: timedOut ? 124 : code ?? 1,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function resolvePython(): Promise<Command | null> {
|
|
59
|
+
if (pythonCommand !== undefined) return pythonCommand;
|
|
60
|
+
const candidates: Command[] = process.env.LARKUP_PYTHON
|
|
61
|
+
? [{ executable: process.env.LARKUP_PYTHON, args: [] }]
|
|
62
|
+
: process.platform === 'win32'
|
|
63
|
+
? [
|
|
64
|
+
{ executable: 'py', args: ['-3'] },
|
|
65
|
+
{ executable: 'python', args: [] },
|
|
66
|
+
]
|
|
67
|
+
: [
|
|
68
|
+
{ executable: 'python3', args: [] },
|
|
69
|
+
{ executable: 'python', args: [] },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
if ((await run(candidate, ['--version'])).exitCode === 0) {
|
|
75
|
+
pythonCommand = candidate;
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
pythonCommand = null;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertSafeFileName(name: string) {
|
|
87
|
+
if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) {
|
|
88
|
+
throw new Error('Sandbox file names must be plain file names.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pythonWrapper(code: string, outputDir: string) {
|
|
93
|
+
return `import base64, os, sys, traceback
|
|
94
|
+
os.makedirs(${JSON.stringify(outputDir)}, exist_ok=True)
|
|
95
|
+
os.environ.setdefault('MPLBACKEND', 'Agg')
|
|
96
|
+
try:
|
|
97
|
+
import numpy as np
|
|
98
|
+
import pandas as pd
|
|
99
|
+
except ImportError:
|
|
100
|
+
pass
|
|
101
|
+
try:
|
|
102
|
+
import matplotlib
|
|
103
|
+
matplotlib.use('Agg')
|
|
104
|
+
import matplotlib.pyplot as plt
|
|
105
|
+
def _save_figures(*args, **kwargs):
|
|
106
|
+
for i, number in enumerate(plt.get_fignums()):
|
|
107
|
+
plt.figure(number).savefig(os.path.join(${JSON.stringify(
|
|
108
|
+
outputDir,
|
|
109
|
+
)}, f'chart_{i}.png'), dpi=150, bbox_inches='tight')
|
|
110
|
+
plt.close('all')
|
|
111
|
+
plt.show = _save_figures
|
|
112
|
+
except ImportError:
|
|
113
|
+
pass
|
|
114
|
+
try:
|
|
115
|
+
exec(compile(base64.b64decode(${JSON.stringify(
|
|
116
|
+
Buffer.from(code).toString('base64'),
|
|
117
|
+
)}), 'analysis.py', 'exec'))
|
|
118
|
+
if 'plt' in globals() and plt.get_fignums():
|
|
119
|
+
plt.show()
|
|
120
|
+
except Exception as error:
|
|
121
|
+
traceback.print_exception(error, file=sys.stderr)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Checks whether the host can run Python analysis without Docker. */
|
|
127
|
+
export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
|
|
128
|
+
const python = await resolvePython();
|
|
129
|
+
if (!python) {
|
|
130
|
+
return {
|
|
131
|
+
status: 'error',
|
|
132
|
+
backend: 'local',
|
|
133
|
+
error:
|
|
134
|
+
'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { status: 'ready', backend: 'local' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Executes trusted code in a temporary host workspace and collects its output. */
|
|
141
|
+
export async function executeLocally(request: ExecutionRequest): Promise<ExecutionResult> {
|
|
142
|
+
const startTime = Date.now();
|
|
143
|
+
const tempDir = path.join(os.tmpdir(), `larkup-local-${randomUUID()}`);
|
|
144
|
+
const inputDir = path.join(tempDir, 'input');
|
|
145
|
+
const outputDir = path.join(tempDir, 'output');
|
|
146
|
+
try {
|
|
147
|
+
await fs.mkdir(inputDir, { recursive: true });
|
|
148
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
149
|
+
for (const file of request.files ?? []) {
|
|
150
|
+
assertSafeFileName(file.name);
|
|
151
|
+
await fs.writeFile(
|
|
152
|
+
path.join(inputDir, file.name),
|
|
153
|
+
file.isBase64 ? Buffer.from(file.content, 'base64') : file.content,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const isPython = request.language === 'python';
|
|
158
|
+
const command = isPython ? await resolvePython() : { executable: process.execPath, args: [] };
|
|
159
|
+
if (!command) {
|
|
160
|
+
return {
|
|
161
|
+
stdout: '',
|
|
162
|
+
stderr:
|
|
163
|
+
'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
|
|
164
|
+
exitCode: 1,
|
|
165
|
+
artifacts: [],
|
|
166
|
+
executionTimeMs: Date.now() - startTime,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const script = path.join(tempDir, isPython ? 'run.py' : 'run.js');
|
|
171
|
+
const source = isPython
|
|
172
|
+
? pythonWrapper(request.code, outputDir)
|
|
173
|
+
: `process.env.LARKUP_OUTPUT_DIR = ${JSON.stringify(outputDir)};\n${request.code}`;
|
|
174
|
+
await fs.writeFile(script, source, 'utf8');
|
|
175
|
+
const result = await run(command, [script], inputDir, request.timeout ?? 30_000);
|
|
176
|
+
const artifacts: ExecutionArtifact[] = [];
|
|
177
|
+
for (const name of await fs.readdir(outputDir).catch(() => [])) {
|
|
178
|
+
const filePath = path.join(outputDir, name);
|
|
179
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
180
|
+
if (!stat?.isFile()) continue;
|
|
181
|
+
artifacts.push({
|
|
182
|
+
name,
|
|
183
|
+
mimeType: getMimeType(name),
|
|
184
|
+
data: (await fs.readFile(filePath)).toString('base64'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { ...result, artifacts, executionTimeMs: Date.now() - startTime };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
stdout: '',
|
|
191
|
+
stderr: error instanceof Error ? error.message : 'Local execution failed.',
|
|
192
|
+
exitCode: 1,
|
|
193
|
+
artifacts: [],
|
|
194
|
+
executionTimeMs: Date.now() - startTime,
|
|
195
|
+
};
|
|
196
|
+
} finally {
|
|
197
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const MIME_TYPES: Record<string, string> = {
|
|
2
|
+
png: 'image/png',
|
|
3
|
+
jpg: 'image/jpeg',
|
|
4
|
+
jpeg: 'image/jpeg',
|
|
5
|
+
svg: 'image/svg+xml',
|
|
6
|
+
gif: 'image/gif',
|
|
7
|
+
csv: 'text/csv',
|
|
8
|
+
json: 'application/json',
|
|
9
|
+
txt: 'text/plain',
|
|
10
|
+
html: 'text/html',
|
|
11
|
+
pdf: 'application/pdf',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function getMimeType(filename: string): string {
|
|
15
|
+
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
|
|
16
|
+
return MIME_TYPES[ext] ?? 'application/octet-stream';
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const listMock = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock('@browserbasehq/sdk', () => ({
|
|
6
|
+
default: vi.fn().mockImplementation(function () {
|
|
7
|
+
return { projects: { list: listMock } };
|
|
8
|
+
}),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { browserbaseAdapter } = await import('./browserbase.js');
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
listMock.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('browserbaseAdapter.execute', () => {
|
|
18
|
+
it('always fails fast — Browserbase is browser automation, not a code-execution sandbox', async () => {
|
|
19
|
+
const result = await browserbaseAdapter.execute({ code: 'print(1)', language: 'python' }, {
|
|
20
|
+
apiKey: 'k',
|
|
21
|
+
projectId: 'p',
|
|
22
|
+
});
|
|
23
|
+
expect(result.exitCode).toBe(1);
|
|
24
|
+
expect(result.stderr).toContain('Playwright/Puppeteer');
|
|
25
|
+
expect(listMock).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('browserbaseAdapter.verifyCredentials', () => {
|
|
30
|
+
it('requires both apiKey and projectId', async () => {
|
|
31
|
+
await expect(browserbaseAdapter.verifyCredentials({ apiKey: 'k' })).rejects.toThrow(
|
|
32
|
+
'Browserbase API key and project ID are both required.',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('checks that the project is visible to the API key', async () => {
|
|
37
|
+
listMock.mockResolvedValue([{ id: 'other-project' }]);
|
|
38
|
+
await expect(browserbaseAdapter.verifyCredentials({ apiKey: 'k', projectId: 'p' })).rejects.toThrow(
|
|
39
|
+
'cannot see the given Browserbase project ID',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('succeeds when the project ID is in the returned list', async () => {
|
|
44
|
+
listMock.mockResolvedValue([{ id: 'p' }]);
|
|
45
|
+
await expect(browserbaseAdapter.verifyCredentials({ apiKey: 'k', projectId: 'p' })).resolves.toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('browserbaseAdapter.healthCheck', () => {
|
|
50
|
+
it('reports status "unsupported" even when credentials are valid', async () => {
|
|
51
|
+
listMock.mockResolvedValue([{ id: 'p' }]);
|
|
52
|
+
const result = await browserbaseAdapter.healthCheck({ apiKey: 'k', projectId: 'p' });
|
|
53
|
+
expect(result.status).toBe('unsupported');
|
|
54
|
+
});
|
|
55
|
+
});
|