@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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +2 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/docker-runner.js +35 -21
  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 +23 -6
  39. package/src/docker-runner.ts +39 -27
  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.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": "^4.0.7"
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": "^3.3.38",
37
- "typescript": "5.7.3"
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
  }
@@ -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: Docker | null = null;
27
+ let dockerInstance: any | null = null;
29
28
 
30
- function getDocker(): Docker {
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: 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',
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: Docker.Container | null = null;
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}/run.py:/sandbox/run.py:ro`,
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
+ });