@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.
- package/.turbo/turbo-build.log +6 -0
- package/CHANGELOG.md +7 -0
- package/Dockerfile +30 -0
- package/LICENSE +176 -0
- package/dist/docker-runner.d.ts +12 -0
- package/dist/docker-runner.js +438 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/sandbox-manager.d.ts +18 -0
- package/dist/sandbox-manager.js +97 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +7 -0
- package/package.json +43 -0
- package/src/docker-runner.ts +483 -0
- package/src/index.ts +26 -0
- package/src/sandbox-manager.ts +124 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
+
}
|