@larkup/sandbox 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +6 -0
  3. package/dist/docker-runner.js +27 -15
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +3 -1
  6. package/dist/local-runner.d.ts +5 -0
  7. package/dist/local-runner.js +176 -0
  8. package/dist/mime.d.ts +1 -0
  9. package/dist/mime.js +16 -0
  10. package/dist/providers/browserbase.d.ts +12 -0
  11. package/dist/providers/browserbase.js +61 -0
  12. package/dist/providers/cloudflare.d.ts +5 -0
  13. package/dist/providers/cloudflare.js +55 -0
  14. package/dist/providers/daytona.d.ts +12 -0
  15. package/dist/providers/daytona.js +84 -0
  16. package/dist/providers/e2b.d.ts +9 -0
  17. package/dist/providers/e2b.js +70 -0
  18. package/dist/providers/flyio.d.ts +9 -0
  19. package/dist/providers/flyio.js +98 -0
  20. package/dist/providers/index.d.ts +13 -0
  21. package/dist/providers/index.js +26 -0
  22. package/dist/providers/modal.d.ts +8 -0
  23. package/dist/providers/modal.js +96 -0
  24. package/dist/providers/northflank.d.ts +9 -0
  25. package/dist/providers/northflank.js +122 -0
  26. package/dist/providers/vercel.d.ts +9 -0
  27. package/dist/providers/vercel.js +79 -0
  28. package/dist/providers/webcontainers.d.ts +13 -0
  29. package/dist/providers/webcontainers.js +28 -0
  30. package/dist/registry.d.ts +20 -0
  31. package/dist/registry.js +281 -0
  32. package/dist/remote-exec.d.ts +42 -0
  33. package/dist/remote-exec.js +144 -0
  34. package/dist/sandbox-manager.d.ts +5 -2
  35. package/dist/sandbox-manager.js +67 -46
  36. package/dist/types.d.ts +58 -9
  37. package/dist/types.js +1 -1
  38. package/package.json +21 -4
  39. package/src/docker-runner.ts +29 -19
  40. package/src/index.ts +11 -2
  41. package/src/local-runner.test.ts +22 -0
  42. package/src/local-runner.ts +199 -0
  43. package/src/mime.ts +17 -0
  44. package/src/providers/browserbase.test.ts +55 -0
  45. package/src/providers/browserbase.ts +88 -0
  46. package/src/providers/cloudflare.test.ts +63 -0
  47. package/src/providers/cloudflare.ts +72 -0
  48. package/src/providers/daytona.test.ts +60 -0
  49. package/src/providers/daytona.ts +144 -0
  50. package/src/providers/e2b.test.ts +100 -0
  51. package/src/providers/e2b.ts +115 -0
  52. package/src/providers/flyio.test.ts +69 -0
  53. package/src/providers/flyio.ts +148 -0
  54. package/src/providers/index.ts +42 -0
  55. package/src/providers/modal.test.ts +107 -0
  56. package/src/providers/modal.ts +167 -0
  57. package/src/providers/northflank.test.ts +110 -0
  58. package/src/providers/northflank.ts +199 -0
  59. package/src/providers/vercel.test.ts +84 -0
  60. package/src/providers/vercel.ts +143 -0
  61. package/src/providers/webcontainers.test.ts +21 -0
  62. package/src/providers/webcontainers.ts +36 -0
  63. package/src/registry.test.ts +84 -0
  64. package/src/registry.ts +308 -0
  65. package/src/remote-exec.test.ts +155 -0
  66. package/src/remote-exec.ts +190 -0
  67. package/src/sandbox-manager.test.ts +74 -0
  68. package/src/sandbox-manager.ts +71 -51
  69. package/src/types.ts +81 -23
  70. package/tsconfig.build.json +4 -0
  71. package/vitest.config.ts +9 -0
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type SandboxBackend = "docker" | "e2b" | "modal" | "custom";
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
- remote?: RemoteConfig;
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 = "python" | "javascript" | "typescript";
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 = "ready" | "docker-not-found" | "image-not-found" | "building-image" | "error";
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
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_DOCKER_CONFIG = {
2
- imageName: "larkup-sandbox:latest",
2
+ imageName: 'larkup-sandbox:latest',
3
3
  memoryMB: 512,
4
4
  cpuShares: 1024,
5
5
  timeoutMs: 30_000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larkup/sandbox",
3
- "version": "0.1.2",
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
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
+ "@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"
49
+ },
35
50
  "devDependencies": {
36
51
  "@types/dockerode": "^4.0.1",
37
- "typescript": "5.7.3"
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
  }
@@ -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: err.message ?? 'Docker daemon not available',
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}/run.py:/sandbox/run.py:ro`,
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
+ });