@larkup/sandbox 0.1.1

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.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Docker-based sandbox runner.
3
+ *
4
+ * Uses `dockerode` to manage ephemeral containers for code execution.
5
+ * Each execution spins up a container, runs the code, captures output +
6
+ * generated files, then auto-removes the container.
7
+ */
8
+
9
+ import Docker from 'dockerode';
10
+ import { promises as fs } from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import { randomUUID } from 'node:crypto';
14
+ import type {
15
+ DockerConfig,
16
+ ExecutionRequest,
17
+ ExecutionResult,
18
+ ExecutionArtifact,
19
+ SandboxHealthCheck,
20
+ DEFAULT_DOCKER_CONFIG,
21
+ } from './types.js';
22
+
23
+ const SANDBOX_IMAGE = 'larkup-sandbox:latest';
24
+ const OUTPUT_DIR = '/sandbox/output';
25
+ const INPUT_DIR = '/sandbox/input';
26
+ const DOCKERFILE_DIR = path.join(import.meta.dirname ?? __dirname, '..');
27
+
28
+ let dockerInstance: Docker | null = null;
29
+
30
+ function getDocker(): Docker {
31
+ if (!dockerInstance) {
32
+ dockerInstance = new Docker();
33
+ }
34
+ return dockerInstance;
35
+ }
36
+
37
+ /* ------------------------------------------------------------------ */
38
+ /* Health check */
39
+ /* ------------------------------------------------------------------ */
40
+
41
+ export async function checkDockerHealth(): Promise<SandboxHealthCheck> {
42
+ const docker = getDocker();
43
+ try {
44
+ const info = await docker.version();
45
+ const images = await docker.listImages({
46
+ filters: { reference: [SANDBOX_IMAGE] },
47
+ });
48
+ return {
49
+ status: images.length > 0 ? 'ready' : 'image-not-found',
50
+ backend: 'docker',
51
+ dockerVersion: info.Version,
52
+ imageReady: images.length > 0,
53
+ };
54
+ } catch (err: any) {
55
+ return {
56
+ status: 'docker-not-found',
57
+ backend: 'docker',
58
+ error: err.message ?? 'Docker daemon not available',
59
+ };
60
+ }
61
+ }
62
+
63
+ /* ------------------------------------------------------------------ */
64
+ /* Image management */
65
+ /* ------------------------------------------------------------------ */
66
+
67
+ export async function buildSandboxImage(onProgress?: (msg: string) => void): Promise<void> {
68
+ const docker = getDocker();
69
+ const dockerfilePath = path.join(DOCKERFILE_DIR, 'Dockerfile');
70
+
71
+ // Check if Dockerfile exists in the package
72
+ try {
73
+ await fs.access(dockerfilePath);
74
+ } catch {
75
+ throw new Error(
76
+ `Sandbox Dockerfile not found at ${dockerfilePath}. ` +
77
+ `Ensure the @larkup/sandbox package is properly installed.`,
78
+ );
79
+ }
80
+
81
+ onProgress?.('Building sandbox image…');
82
+
83
+ const stream = await docker.buildImage(
84
+ { context: DOCKERFILE_DIR, src: ['Dockerfile'] },
85
+ { t: SANDBOX_IMAGE },
86
+ );
87
+
88
+ // Follow the build stream to completion
89
+ await new Promise<void>((resolve, reject) => {
90
+ docker.modem.followProgress(
91
+ stream,
92
+ (err: Error | null) => {
93
+ if (err) reject(err);
94
+ else resolve();
95
+ },
96
+ (event: any) => {
97
+ if (event.stream) {
98
+ onProgress?.(event.stream.trim());
99
+ }
100
+ },
101
+ );
102
+ });
103
+
104
+ onProgress?.('Sandbox image built successfully ✓');
105
+ }
106
+
107
+ export async function ensureImage(): Promise<boolean> {
108
+ const docker = getDocker();
109
+ const images = await docker.listImages({
110
+ filters: { reference: [SANDBOX_IMAGE] },
111
+ });
112
+ if (images.length > 0) return true;
113
+
114
+ try {
115
+ await buildSandboxImage();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /* ------------------------------------------------------------------ */
123
+ /* Code execution */
124
+ /* ------------------------------------------------------------------ */
125
+
126
+ /**
127
+ * Build the Python wrapper script that:
128
+ * 1. Sets up pandas compatibility shims to handle deprecated APIs
129
+ * 2. Auto-imports common data analysis modules
130
+ * 3. Runs the user code with robust error handling
131
+ * 4. Saves any matplotlib figures to /sandbox/output/
132
+ * 5. Lists generated artifacts
133
+ */
134
+ function wrapPythonCode(code: string): string {
135
+ return `
136
+ import sys, os, json, traceback
137
+
138
+ # Ensure output directory exists
139
+ os.makedirs("${OUTPUT_DIR}", exist_ok=True)
140
+
141
+ # ============================================================
142
+ # SAFETY PREAMBLE: Suppress warnings & patch deprecated APIs
143
+ # ============================================================
144
+ import warnings
145
+ warnings.filterwarnings('ignore', category=FutureWarning)
146
+ warnings.filterwarnings('ignore', category=DeprecationWarning)
147
+ warnings.filterwarnings('ignore', category=UserWarning)
148
+
149
+ # Auto-import common data analysis modules
150
+ import numpy as np
151
+
152
+ try:
153
+ import pandas as pd
154
+
155
+ # --- Patch 1: DataFrame.append() was removed in pandas 2.0 ---
156
+ if not hasattr(pd.DataFrame, 'append'):
157
+ def _df_append(self, other, ignore_index=False, verify_integrity=False, sort=False, **kwargs):
158
+ """Backwards-compatible append using pd.concat."""
159
+ if isinstance(other, (list, tuple)):
160
+ to_concat = [self] + list(other)
161
+ elif isinstance(other, dict):
162
+ to_concat = [self, pd.DataFrame([other])]
163
+ else:
164
+ to_concat = [self, other]
165
+ return pd.concat(to_concat, ignore_index=ignore_index, verify_integrity=verify_integrity, sort=sort)
166
+ pd.DataFrame.append = _df_append
167
+
168
+ # --- Patch 2: Series.append() was removed in pandas 2.0 ---
169
+ if not hasattr(pd.Series, 'append'):
170
+ def _series_append(self, to_append, ignore_index=False, verify_integrity=False):
171
+ """Backwards-compatible Series.append using pd.concat."""
172
+ if isinstance(to_append, (list, tuple)):
173
+ to_concat = [self] + list(to_append)
174
+ else:
175
+ to_concat = [self, to_append]
176
+ return pd.concat(to_concat, ignore_index=ignore_index, verify_integrity=verify_integrity)
177
+ pd.Series.append = _series_append
178
+
179
+ # --- Patch 3: Fix deprecated resample frequency aliases ---
180
+ # "M" -> "ME", "Q" -> "QE", "Y" -> "YE", "H" -> "h", "T" -> "min", "S" -> "s"
181
+ _original_resample = pd.DataFrame.resample
182
+ _FREQ_ALIAS_MAP = {
183
+ 'M': 'ME', 'Q': 'QE', 'Y': 'YE', 'A': 'YE',
184
+ 'BM': 'BME', 'BQ': 'BQE', 'BA': 'BYE', 'BY': 'BYE',
185
+ 'BMS': 'BMS', 'QS': 'QS', 'AS': 'YS', 'BAS': 'BYS',
186
+ 'H': 'h', 'T': 'min', 'S': 's', 'L': 'ms', 'U': 'us', 'N': 'ns',
187
+ }
188
+ def _patched_resample(self, rule, *args, **kwargs):
189
+ if isinstance(rule, str) and rule in _FREQ_ALIAS_MAP:
190
+ rule = _FREQ_ALIAS_MAP[rule]
191
+ return _original_resample(self, rule, *args, **kwargs)
192
+ pd.DataFrame.resample = _patched_resample
193
+
194
+ # Also patch Series.resample
195
+ _original_series_resample = pd.Series.resample
196
+ def _patched_series_resample(self, rule, *args, **kwargs):
197
+ if isinstance(rule, str) and rule in _FREQ_ALIAS_MAP:
198
+ rule = _FREQ_ALIAS_MAP[rule]
199
+ return _original_series_resample(self, rule, *args, **kwargs)
200
+ pd.Series.resample = _patched_series_resample
201
+
202
+ # --- Patch 4: Fix pd.Grouper freq aliases ---
203
+ _original_grouper = pd.Grouper
204
+ class _PatchedGrouper(_original_grouper):
205
+ def __init__(self, *args, **kwargs):
206
+ if 'freq' in kwargs and isinstance(kwargs['freq'], str):
207
+ kwargs['freq'] = _FREQ_ALIAS_MAP.get(kwargs['freq'], kwargs['freq'])
208
+ super().__init__(*args, **kwargs)
209
+ pd.Grouper = _PatchedGrouper
210
+
211
+ except ImportError:
212
+ pass
213
+
214
+ # ============================================================
215
+ # Patch matplotlib to auto-save figures
216
+ # ============================================================
217
+ _figure_count = [0]
218
+ try:
219
+ import matplotlib
220
+ matplotlib.use("Agg")
221
+ import matplotlib.pyplot as plt
222
+
223
+ _original_show = plt.show
224
+ def _patched_show(*args, **kwargs):
225
+ for fig_num in plt.get_fignums():
226
+ fig = plt.figure(fig_num)
227
+ fname = f"${OUTPUT_DIR}/chart_{_figure_count[0]}.png"
228
+ fig.savefig(fname, dpi=150, bbox_inches="tight", facecolor="white")
229
+ _figure_count[0] += 1
230
+ plt.close("all")
231
+ plt.show = _patched_show
232
+ except ImportError:
233
+ pass
234
+
235
+ # ============================================================
236
+ # Execute user code with robust error handling
237
+ # ============================================================
238
+ try:
239
+ exec(${JSON.stringify(code)})
240
+
241
+ # Auto-save any remaining matplotlib figures
242
+ try:
243
+ import matplotlib.pyplot as plt
244
+ if plt.get_fignums():
245
+ plt.show()
246
+ except (ImportError, Exception):
247
+ pass
248
+ except SystemExit as e:
249
+ # Let explicit sys.exit() calls through
250
+ if e.code != 0:
251
+ sys.exit(e.code)
252
+ except Exception as e:
253
+ # Print a clean, user-readable error instead of raw traceback
254
+ error_type = type(e).__name__
255
+ error_msg = str(e)
256
+ print(f"Error ({error_type}): {error_msg}", file=sys.stderr)
257
+ # Also print the relevant part of the traceback for debugging
258
+ tb = traceback.extract_tb(sys.exc_info()[2])
259
+ # Filter to only show the exec'd code frames (skip wrapper frames)
260
+ user_frames = [f for f in tb if f.filename == '<string>']
261
+ if user_frames:
262
+ last = user_frames[-1]
263
+ print(f" at line {last.lineno}: {last.line}", file=sys.stderr)
264
+ sys.exit(1)
265
+
266
+ # List generated artifacts
267
+ artifacts = []
268
+ if os.path.isdir("${OUTPUT_DIR}"):
269
+ for f in sorted(os.listdir("${OUTPUT_DIR}")):
270
+ fp = os.path.join("${OUTPUT_DIR}", f)
271
+ if os.path.isfile(fp):
272
+ artifacts.append(f)
273
+ if artifacts:
274
+ print("\\n__ARTIFACTS__:" + json.dumps(artifacts))
275
+ `;
276
+ }
277
+
278
+ export async function executeInDocker(
279
+ request: ExecutionRequest,
280
+ config: DockerConfig,
281
+ ): Promise<ExecutionResult> {
282
+ const docker = getDocker();
283
+ const startTime = Date.now();
284
+
285
+ // Create a temporary directory for input files
286
+ const tmpDir = path.join(os.tmpdir(), `larkup-sandbox-${randomUUID()}`);
287
+ const inputDir = path.join(tmpDir, 'input');
288
+ const outputDir = path.join(tmpDir, 'output');
289
+ await fs.mkdir(inputDir, { recursive: true });
290
+ await fs.mkdir(outputDir, { recursive: true });
291
+
292
+ // Write input files
293
+ if (request.files) {
294
+ for (const file of request.files) {
295
+ const filePath = path.join(inputDir, file.name);
296
+ if (file.isBase64) {
297
+ await fs.writeFile(filePath, Buffer.from(file.content, 'base64'));
298
+ } else {
299
+ await fs.writeFile(filePath, file.content, 'utf8');
300
+ }
301
+ }
302
+ }
303
+
304
+ // Prepare the execution code
305
+ let execCode: string;
306
+ let cmd: string[];
307
+
308
+ if (request.language === 'python') {
309
+ execCode = wrapPythonCode(request.code);
310
+ const scriptPath = path.join(tmpDir, 'run.py');
311
+ await fs.writeFile(scriptPath, execCode, 'utf8');
312
+ cmd = ['python3', '/sandbox/run.py'];
313
+ } else {
314
+ // JavaScript/TypeScript (future)
315
+ const scriptPath = path.join(tmpDir, 'run.js');
316
+ await fs.writeFile(scriptPath, request.code, 'utf8');
317
+ cmd = ['node', '/sandbox/run.js'];
318
+ }
319
+
320
+ const timeout = request.timeout ?? config.timeoutMs;
321
+
322
+ let container: Docker.Container | null = null;
323
+ try {
324
+ container = await docker.createContainer({
325
+ Image: config.imageName,
326
+ Cmd: cmd,
327
+ Entrypoint: [],
328
+ WorkingDir: '/sandbox/input',
329
+ HostConfig: {
330
+ AutoRemove: false,
331
+ Memory: config.memoryMB * 1024 * 1024,
332
+ CpuShares: config.cpuShares,
333
+ NetworkMode: config.networkDisabled ? 'none' : 'bridge',
334
+ Binds: [
335
+ `${inputDir}:${INPUT_DIR}:ro`,
336
+ `${outputDir}:${OUTPUT_DIR}:rw`,
337
+ `${tmpDir}/run.py:/sandbox/run.py:ro`,
338
+ ],
339
+ ReadonlyRootfs: false,
340
+ },
341
+ Tty: false,
342
+ AttachStdout: true,
343
+ AttachStderr: true,
344
+ });
345
+
346
+ await container.start();
347
+
348
+ // Wait for completion with timeout
349
+ const waitPromise = container.wait();
350
+ const timeoutPromise = new Promise<never>((_, reject) => {
351
+ setTimeout(async () => {
352
+ try {
353
+ await container?.kill();
354
+ } catch {
355
+ /* container may have already stopped */
356
+ }
357
+ reject(new Error(`Execution timed out after ${timeout}ms`));
358
+ }, timeout);
359
+ });
360
+
361
+ const result = await Promise.race([waitPromise, timeoutPromise]);
362
+
363
+ // Capture logs
364
+ const logs = await container.logs({
365
+ stdout: true,
366
+ stderr: true,
367
+ follow: false,
368
+ });
369
+
370
+ const logStr = demuxDockerStream(logs as unknown as Buffer);
371
+
372
+ // Collect artifacts from output directory
373
+ const artifacts: ExecutionArtifact[] = [];
374
+ try {
375
+ const files = await fs.readdir(outputDir);
376
+ for (const file of files) {
377
+ const filePath = path.join(outputDir, file);
378
+ const content = await fs.readFile(filePath);
379
+ const ext = path.extname(file).toLowerCase();
380
+ const mimeType = getMimeType(ext);
381
+ artifacts.push({
382
+ name: file,
383
+ mimeType,
384
+ data: content.toString('base64'),
385
+ });
386
+ }
387
+ } catch {
388
+ /* output dir may not exist */
389
+ }
390
+
391
+ // Parse stdout/stderr from demuxed logs
392
+ const { stdout, stderr } = logStr;
393
+
394
+ return {
395
+ stdout: cleanArtifactMarkers(stdout),
396
+ stderr,
397
+ exitCode: (result as any).StatusCode ?? 1,
398
+ artifacts,
399
+ executionTimeMs: Date.now() - startTime,
400
+ };
401
+ } catch (err: any) {
402
+ return {
403
+ stdout: '',
404
+ stderr: err.message ?? 'Execution failed',
405
+ exitCode: 1,
406
+ artifacts: [],
407
+ executionTimeMs: Date.now() - startTime,
408
+ };
409
+ } finally {
410
+ // Cleanup
411
+ if (container) {
412
+ try {
413
+ await container.remove({ force: true });
414
+ } catch {
415
+ /* already removed */
416
+ }
417
+ }
418
+ try {
419
+ await fs.rm(tmpDir, { recursive: true, force: true });
420
+ } catch {
421
+ /* best effort */
422
+ }
423
+ }
424
+ }
425
+
426
+ /* ------------------------------------------------------------------ */
427
+ /* Helpers */
428
+ /* ------------------------------------------------------------------ */
429
+
430
+ /**
431
+ * Docker multiplexes stdout/stderr into a single stream with 8-byte headers.
432
+ * Header format: [type(1)][0(3)][size(4)] where type: 1=stdout, 2=stderr
433
+ */
434
+ function demuxDockerStream(buffer: Buffer): {
435
+ stdout: string;
436
+ stderr: string;
437
+ } {
438
+ let stdout = '';
439
+ let stderr = '';
440
+
441
+ if (typeof buffer === 'string') {
442
+ return { stdout: buffer, stderr: '' };
443
+ }
444
+
445
+ let offset = 0;
446
+ while (offset < buffer.length) {
447
+ if (offset + 8 > buffer.length) break;
448
+ const type = buffer.readUInt8(offset);
449
+ const size = buffer.readUInt32BE(offset + 4);
450
+ offset += 8;
451
+ if (offset + size > buffer.length) break;
452
+ const payload = buffer.subarray(offset, offset + size).toString('utf8');
453
+ if (type === 1) stdout += payload;
454
+ else if (type === 2) stderr += payload;
455
+ offset += size;
456
+ }
457
+
458
+ if (!stdout && !stderr && buffer.length > 0) {
459
+ stdout = buffer.toString('utf8');
460
+ }
461
+
462
+ return { stdout, stderr };
463
+ }
464
+
465
+ function cleanArtifactMarkers(stdout: string): string {
466
+ return stdout.replace(/\n__ARTIFACTS__:.*$/m, '').trimEnd();
467
+ }
468
+
469
+ function getMimeType(ext: string): string {
470
+ const map: Record<string, string> = {
471
+ '.png': 'image/png',
472
+ '.jpg': 'image/jpeg',
473
+ '.jpeg': 'image/jpeg',
474
+ '.svg': 'image/svg+xml',
475
+ '.gif': 'image/gif',
476
+ '.csv': 'text/csv',
477
+ '.json': 'application/json',
478
+ '.txt': 'text/plain',
479
+ '.html': 'text/html',
480
+ '.pdf': 'application/pdf',
481
+ };
482
+ return map[ext] ?? 'application/octet-stream';
483
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @larkup/sandbox — Main entry point.
3
+ *
4
+ * Re-exports the public API for the sandbox package.
5
+ */
6
+
7
+ export { SandboxManager } from './sandbox-manager.js';
8
+ export {
9
+ checkDockerHealth,
10
+ buildSandboxImage,
11
+ ensureImage,
12
+ executeInDocker,
13
+ } from './docker-runner.js';
14
+ export type {
15
+ SandboxBackend,
16
+ SandboxConfig,
17
+ DockerConfig,
18
+ RemoteConfig,
19
+ SandboxLanguage,
20
+ SandboxFile,
21
+ ExecutionRequest,
22
+ ExecutionResult,
23
+ ExecutionArtifact,
24
+ SandboxStatus,
25
+ SandboxHealthCheck,
26
+ } from './types.js';
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Sandbox manager — high-level API for code execution.
3
+ *
4
+ * Provides a unified interface that routes to the appropriate backend
5
+ * (Docker, E2B, etc.) based on configuration. Handles health checks,
6
+ * image setup, and execution lifecycle.
7
+ */
8
+
9
+ import type {
10
+ SandboxConfig,
11
+ ExecutionRequest,
12
+ ExecutionResult,
13
+ SandboxHealthCheck,
14
+ DockerConfig,
15
+ DEFAULT_DOCKER_CONFIG,
16
+ } from './types.js';
17
+ import {
18
+ checkDockerHealth,
19
+ buildSandboxImage,
20
+ ensureImage,
21
+ executeInDocker,
22
+ } from './docker-runner.js';
23
+
24
+ const defaultDockerConfig: DockerConfig = {
25
+ imageName: 'larkup-sandbox:latest',
26
+ memoryMB: 512,
27
+ cpuShares: 1024,
28
+ timeoutMs: 30_000,
29
+ networkDisabled: true,
30
+ };
31
+
32
+ export class SandboxManager {
33
+ private config: SandboxConfig;
34
+
35
+ constructor(config?: Partial<SandboxConfig>) {
36
+ this.config = {
37
+ backend: config?.backend ?? 'docker',
38
+ docker: config?.docker,
39
+ remote: config?.remote,
40
+ };
41
+ }
42
+
43
+ /** Check if the sandbox backend is available and ready. */
44
+ async healthCheck(): Promise<SandboxHealthCheck> {
45
+ switch (this.config.backend) {
46
+ case 'docker':
47
+ return checkDockerHealth();
48
+
49
+ case 'e2b':
50
+ case 'modal':
51
+ case 'custom':
52
+ return {
53
+ status: 'error',
54
+ backend: this.config.backend,
55
+ error: `${this.config.backend} backend is not yet implemented. Use Docker for local execution.`,
56
+ };
57
+
58
+ default:
59
+ return {
60
+ status: 'error',
61
+ backend: this.config.backend,
62
+ error: `Unknown backend: ${this.config.backend}`,
63
+ };
64
+ }
65
+ }
66
+
67
+ /** Set up the sandbox backend (e.g., build Docker image). */
68
+ async setup(onProgress?: (msg: string) => void): Promise<void> {
69
+ switch (this.config.backend) {
70
+ case 'docker':
71
+ await buildSandboxImage(onProgress);
72
+ break;
73
+
74
+ default:
75
+ throw new Error(`Setup not supported for backend: ${this.config.backend}`);
76
+ }
77
+ }
78
+
79
+ /** Execute code in the sandbox. */
80
+ async execute(request: ExecutionRequest): Promise<ExecutionResult> {
81
+ switch (this.config.backend) {
82
+ case 'docker': {
83
+ // Ensure image is available
84
+ const imageReady = await ensureImage();
85
+ if (!imageReady) {
86
+ return {
87
+ stdout: '',
88
+ stderr: 'Sandbox image not available. Run setup first or ensure Docker is installed.',
89
+ exitCode: 1,
90
+ artifacts: [],
91
+ executionTimeMs: 0,
92
+ };
93
+ }
94
+
95
+ const dockerConfig: DockerConfig = {
96
+ ...defaultDockerConfig,
97
+ ...this.config.docker,
98
+ };
99
+
100
+ return executeInDocker(request, dockerConfig);
101
+ }
102
+
103
+ case 'e2b':
104
+ case 'modal':
105
+ case 'custom':
106
+ return {
107
+ stdout: '',
108
+ stderr: `${this.config.backend} backend is not yet implemented.`,
109
+ exitCode: 1,
110
+ artifacts: [],
111
+ executionTimeMs: 0,
112
+ };
113
+
114
+ default:
115
+ return {
116
+ stdout: '',
117
+ stderr: `Unknown backend: ${this.config.backend}`,
118
+ exitCode: 1,
119
+ artifacts: [],
120
+ executionTimeMs: 0,
121
+ };
122
+ }
123
+ }
124
+ }