@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
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.4",
|
|
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
38
|
"dockerode": "^5.0.1"
|
|
34
39
|
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"e2b": "^2.39.0",
|
|
42
|
+
"@vercel/sandbox": "^3.0.0",
|
|
43
|
+
"modal": "^0.9.0",
|
|
44
|
+
"@daytona/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"
|
|
49
|
+
},
|
|
35
50
|
"devDependencies": {
|
|
36
51
|
"@types/dockerode": "^4.0.1",
|
|
37
|
-
"typescript": "
|
|
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
|
@@ -36,10 +36,6 @@ async function getDocker(): Promise<any> {
|
|
|
36
36
|
return dockerInstance;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/* ------------------------------------------------------------------ */
|
|
40
|
-
/* Health check */
|
|
41
|
-
/* ------------------------------------------------------------------ */
|
|
42
|
-
|
|
43
39
|
export async function checkDockerHealth(): Promise<SandboxHealthCheck> {
|
|
44
40
|
const docker = await getDocker();
|
|
45
41
|
try {
|
|
@@ -54,18 +50,19 @@ export async function checkDockerHealth(): Promise<SandboxHealthCheck> {
|
|
|
54
50
|
imageReady: images.length > 0,
|
|
55
51
|
};
|
|
56
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);
|
|
57
56
|
return {
|
|
58
57
|
status: 'docker-not-found',
|
|
59
58
|
backend: 'docker',
|
|
60
|
-
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',
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/* ------------------------------------------------------------------ */
|
|
66
|
-
/* Image management */
|
|
67
|
-
/* ------------------------------------------------------------------ */
|
|
68
|
-
|
|
69
66
|
export async function buildSandboxImage(onProgress?: (msg: string) => void): Promise<void> {
|
|
70
67
|
const docker = await getDocker();
|
|
71
68
|
const dockerfilePath = path.join(DOCKERFILE_DIR, 'Dockerfile');
|
|
@@ -121,10 +118,6 @@ export async function ensureImage(): Promise<boolean> {
|
|
|
121
118
|
}
|
|
122
119
|
}
|
|
123
120
|
|
|
124
|
-
/* ------------------------------------------------------------------ */
|
|
125
|
-
/* Code execution */
|
|
126
|
-
/* ------------------------------------------------------------------ */
|
|
127
|
-
|
|
128
121
|
/**
|
|
129
122
|
* Build the Python wrapper script that:
|
|
130
123
|
* 1. Sets up pandas compatibility shims to handle deprecated APIs
|
|
@@ -294,6 +287,22 @@ export async function executeInDocker(
|
|
|
294
287
|
// Write input files
|
|
295
288
|
if (request.files) {
|
|
296
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
|
+
}
|
|
297
306
|
const filePath = path.join(inputDir, file.name);
|
|
298
307
|
if (file.isBase64) {
|
|
299
308
|
await fs.writeFile(filePath, Buffer.from(file.content, 'base64'));
|
|
@@ -306,16 +315,19 @@ export async function executeInDocker(
|
|
|
306
315
|
// Prepare the execution code
|
|
307
316
|
let execCode: string;
|
|
308
317
|
let cmd: string[];
|
|
318
|
+
let scriptName: string;
|
|
309
319
|
|
|
310
320
|
if (request.language === 'python') {
|
|
311
321
|
execCode = wrapPythonCode(request.code);
|
|
312
322
|
const scriptPath = path.join(tmpDir, 'run.py');
|
|
313
323
|
await fs.writeFile(scriptPath, execCode, 'utf8');
|
|
324
|
+
scriptName = 'run.py';
|
|
314
325
|
cmd = ['python3', '/sandbox/run.py'];
|
|
315
326
|
} else {
|
|
316
327
|
// JavaScript/TypeScript (future)
|
|
317
328
|
const scriptPath = path.join(tmpDir, 'run.js');
|
|
318
329
|
await fs.writeFile(scriptPath, request.code, 'utf8');
|
|
330
|
+
scriptName = 'run.js';
|
|
319
331
|
cmd = ['node', '/sandbox/run.js'];
|
|
320
332
|
}
|
|
321
333
|
|
|
@@ -336,7 +348,7 @@ export async function executeInDocker(
|
|
|
336
348
|
Binds: [
|
|
337
349
|
`${inputDir}:${INPUT_DIR}:ro`,
|
|
338
350
|
`${outputDir}:${OUTPUT_DIR}:rw`,
|
|
339
|
-
`${tmpDir}
|
|
351
|
+
`${tmpDir}/${scriptName}:/sandbox/${scriptName}:ro`,
|
|
340
352
|
],
|
|
341
353
|
ReadonlyRootfs: false,
|
|
342
354
|
},
|
|
@@ -349,8 +361,9 @@ export async function executeInDocker(
|
|
|
349
361
|
|
|
350
362
|
// Wait for completion with timeout
|
|
351
363
|
const waitPromise = container.wait();
|
|
364
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
352
365
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
353
|
-
setTimeout(async () => {
|
|
366
|
+
timeoutHandle = setTimeout(async () => {
|
|
354
367
|
try {
|
|
355
368
|
await container?.kill();
|
|
356
369
|
} catch {
|
|
@@ -361,6 +374,7 @@ export async function executeInDocker(
|
|
|
361
374
|
});
|
|
362
375
|
|
|
363
376
|
const result = await Promise.race([waitPromise, timeoutPromise]);
|
|
377
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
364
378
|
|
|
365
379
|
// Capture logs
|
|
366
380
|
const logs = await container.logs({
|
|
@@ -425,10 +439,6 @@ export async function executeInDocker(
|
|
|
425
439
|
}
|
|
426
440
|
}
|
|
427
441
|
|
|
428
|
-
/* ------------------------------------------------------------------ */
|
|
429
|
-
/* Helpers */
|
|
430
|
-
/* ------------------------------------------------------------------ */
|
|
431
|
-
|
|
432
442
|
/**
|
|
433
443
|
* Docker multiplexes stdout/stderr into a single stream with 8-byte headers.
|
|
434
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
|
+
});
|