@cloudflare/sandbox 0.0.0-603d05f → 0.0.0-66cc85b

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Implements PID namespace isolation to secure the sandbox environment.
5
5
  * Executed commands run in isolated namespaces, preventing them from:
6
- * - Seeing or killing control plane processes (Jupyter, Bun)
6
+ * - Seeing or killing control plane processes (Bun)
7
7
  * - Accessing platform secrets in /proc
8
8
  * - Hijacking control plane ports
9
9
  *
@@ -35,11 +35,11 @@ import type { ProcessRecord, ProcessStatus } from './types';
35
35
  // Configuration constants
36
36
  const CONFIG = {
37
37
  // Timeouts (in milliseconds)
38
- COMMAND_TIMEOUT_MS: 30000, // 30 seconds for command execution
39
38
  READY_TIMEOUT_MS: 5000, // 5 seconds for control process to initialize
40
- CLEANUP_INTERVAL_MS: 30000, // Run cleanup every 30 seconds
41
- TEMP_FILE_MAX_AGE_MS: 60000, // Delete temp files older than 60 seconds
42
39
  SHUTDOWN_GRACE_PERIOD_MS: 500, // Grace period for cleanup on shutdown
40
+ COMMAND_TIMEOUT_MS: parseInt(process.env.COMMAND_TIMEOUT_MS || '30000'), // 30 seconds for command execution
41
+ CLEANUP_INTERVAL_MS: parseInt(process.env.CLEANUP_INTERVAL_MS || '30000'), // Run cleanup every 30 seconds
42
+ TEMP_FILE_MAX_AGE_MS: parseInt(process.env.TEMP_FILE_MAX_AGE_MS || '60000'), // Delete temp files older than 60 seconds
43
43
 
44
44
  // Default paths
45
45
  DEFAULT_CWD: '/workspace',
@@ -121,13 +121,20 @@ export class Session {
121
121
  timeout: NodeJS.Timeout;
122
122
  }>();
123
123
  private processes = new Map<string, ProcessRecord>(); // Session-specific processes
124
-
124
+
125
125
  constructor(private options: SessionOptions) {
126
126
  this.canIsolate = (options.isolation === true) && hasNamespaceSupport();
127
127
  if (options.isolation === true && !this.canIsolate) {
128
128
  console.log(`[Session] Isolation requested for '${options.id}' but not available`);
129
129
  }
130
130
  }
131
+
132
+ /**
133
+ * Check if the session is ready for command execution
134
+ */
135
+ isReady(): boolean {
136
+ return this.ready && this.control !== null && !this.control.killed;
137
+ }
131
138
 
132
139
  async initialize(): Promise<void> {
133
140
  // Use the proper TypeScript control process file
@@ -423,12 +430,11 @@ export class Session {
423
430
 
424
431
  // File Operations - Execute as shell commands to inherit session context
425
432
  async writeFileOperation(path: string, content: string, encoding: string = 'utf-8'): Promise<{ success: boolean; exitCode: number; path: string }> {
426
- // Escape content for safe heredoc usage
427
- const safeContent = content.replace(/'/g, "'\\''");
428
-
429
433
  // Create parent directory if needed, then write file using heredoc
434
+ // Note: The quoted heredoc delimiter 'SANDBOX_EOF' prevents variable expansion
435
+ // and treats the content literally, so no escaping is required
430
436
  const command = `mkdir -p "$(dirname "${path}")" && cat > "${path}" << 'SANDBOX_EOF'
431
- ${safeContent}
437
+ ${content}
432
438
  SANDBOX_EOF`;
433
439
 
434
440
  const result = await this.exec(command);
@@ -440,16 +446,180 @@ SANDBOX_EOF`;
440
446
  };
441
447
  }
442
448
 
443
- async readFileOperation(path: string, encoding: string = 'utf-8'): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
444
- const command = `cat "${path}"`;
445
- const result = await this.exec(command);
446
-
449
+ async readFileOperation(path: string, encoding: string = 'utf-8'): Promise<{
450
+ success: boolean;
451
+ exitCode: number;
452
+ content: string;
453
+ path: string;
454
+ encoding?: 'utf-8' | 'base64';
455
+ isBinary?: boolean;
456
+ mimeType?: string;
457
+ size?: number;
458
+ }> {
459
+ // Step 1: Check if file exists and get metadata
460
+ const statCommand = `stat -c '%s' "${path}" 2>/dev/null || echo "FILE_NOT_FOUND"`;
461
+ const statResult = await this.exec(statCommand);
462
+
463
+ if (statResult.stdout.trim() === 'FILE_NOT_FOUND') {
464
+ // File doesn't exist - return error
465
+ return {
466
+ success: false,
467
+ exitCode: 1,
468
+ content: '',
469
+ path
470
+ };
471
+ }
472
+
473
+ const fileSize = parseInt(statResult.stdout.trim(), 10);
474
+
475
+ // Step 2: Detect MIME type using file command
476
+ const mimeCommand = `file --mime-type -b "${path}"`;
477
+ const mimeResult = await this.exec(mimeCommand);
478
+ const mimeType = mimeResult.stdout.trim();
479
+
480
+ // Step 3: Determine if file is binary based on MIME type
481
+ // Text MIME types: text/*, application/json, application/xml, application/javascript, etc.
482
+ const isBinary = !mimeType.startsWith('text/') &&
483
+ !mimeType.includes('json') &&
484
+ !mimeType.includes('xml') &&
485
+ !mimeType.includes('javascript') &&
486
+ !mimeType.includes('x-empty');
487
+
488
+ // Step 4: Read file with appropriate encoding
489
+ let content: string;
490
+ let actualEncoding: 'utf-8' | 'base64';
491
+
492
+ if (isBinary) {
493
+ // Use base64 for binary files
494
+ const base64Command = `base64 -w 0 "${path}"`;
495
+ const base64Result = await this.exec(base64Command);
496
+ content = base64Result.stdout;
497
+ actualEncoding = 'base64';
498
+ } else {
499
+ // Use cat for text files
500
+ const catCommand = `cat "${path}"`;
501
+ const catResult = await this.exec(catCommand);
502
+ content = catResult.stdout;
503
+ actualEncoding = 'utf-8';
504
+ }
505
+
447
506
  return {
448
- success: result.exitCode === 0,
449
- exitCode: result.exitCode,
450
- content: result.stdout,
451
- path
507
+ success: true,
508
+ exitCode: 0,
509
+ content,
510
+ path,
511
+ encoding: actualEncoding,
512
+ isBinary,
513
+ mimeType,
514
+ size: fileSize
515
+ };
516
+ }
517
+
518
+ async readFileStreamOperation(path: string): Promise<ReadableStream<Uint8Array>> {
519
+ const encoder = new TextEncoder();
520
+
521
+ // Helper to send SSE event
522
+ const sseEvent = (event: any): Uint8Array => {
523
+ return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
452
524
  };
525
+
526
+ // Create streaming response
527
+ return new ReadableStream({
528
+ start: async (controller) => {
529
+ try {
530
+ // Step 1: Get file metadata (same logic as readFileOperation)
531
+ const statCommand = `stat -c '%s' "${path}" 2>/dev/null || echo "FILE_NOT_FOUND"`;
532
+ const statResult = await this.exec(statCommand);
533
+
534
+ if (statResult.stdout.trim() === 'FILE_NOT_FOUND') {
535
+ // File doesn't exist - send error event
536
+ controller.enqueue(sseEvent({
537
+ type: 'error',
538
+ error: `File not found: ${path}`
539
+ }));
540
+ controller.close();
541
+ return;
542
+ }
543
+
544
+ const fileSize = parseInt(statResult.stdout.trim(), 10);
545
+
546
+ // Step 2: Detect MIME type
547
+ const mimeCommand = `file --mime-type -b "${path}"`;
548
+ const mimeResult = await this.exec(mimeCommand);
549
+ const mimeType = mimeResult.stdout.trim();
550
+
551
+ // Step 3: Determine if binary
552
+ const isBinary = !mimeType.startsWith('text/') &&
553
+ !mimeType.includes('json') &&
554
+ !mimeType.includes('xml') &&
555
+ !mimeType.includes('javascript') &&
556
+ !mimeType.includes('x-empty');
557
+
558
+ const encoding: 'utf-8' | 'base64' = isBinary ? 'base64' : 'utf-8';
559
+
560
+ // Step 4: Send metadata event
561
+ controller.enqueue(sseEvent({
562
+ type: 'metadata',
563
+ mimeType,
564
+ size: fileSize,
565
+ isBinary,
566
+ encoding
567
+ }));
568
+
569
+ // Step 5: Stream file in chunks
570
+ // IMPORTANT: Chunk size MUST be divisible by 3 for base64 encoding!
571
+ // Base64 encodes 3 bytes at a time. If chunks aren't aligned,
572
+ // concatenating separately-encoded base64 strings corrupts the data.
573
+ const CHUNK_SIZE = 65535;
574
+ let bytesRead = 0;
575
+
576
+ while (bytesRead < fileSize) {
577
+ const remainingBytes = fileSize - bytesRead;
578
+ const chunkSize = Math.min(CHUNK_SIZE, remainingBytes);
579
+
580
+ // Use dd to read chunk at specific offset
581
+ // bs=1 means 1 byte block size, skip=offset, count=chunkSize
582
+ let chunkCommand: string;
583
+ if (isBinary) {
584
+ // For binary, read and encode as base64
585
+ chunkCommand = `dd if="${path}" bs=1 skip=${bytesRead} count=${chunkSize} 2>/dev/null | base64 -w 0`;
586
+ } else {
587
+ // For text, just read
588
+ chunkCommand = `dd if="${path}" bs=1 skip=${bytesRead} count=${chunkSize} 2>/dev/null`;
589
+ }
590
+
591
+ const chunkResult = await this.exec(chunkCommand);
592
+
593
+ // Send chunk event
594
+ controller.enqueue(sseEvent({
595
+ type: 'chunk',
596
+ data: chunkResult.stdout
597
+ }));
598
+
599
+ bytesRead += chunkSize;
600
+ }
601
+
602
+ // Step 6: Send complete event
603
+ controller.enqueue(sseEvent({
604
+ type: 'complete',
605
+ bytesRead
606
+ }));
607
+
608
+ controller.close();
609
+ } catch (error) {
610
+ // Send error event
611
+ controller.enqueue(sseEvent({
612
+ type: 'error',
613
+ error: error instanceof Error ? error.message : String(error)
614
+ }));
615
+ controller.close();
616
+ }
617
+ },
618
+
619
+ cancel() {
620
+ console.log(`[Session] File stream cancelled for: ${path}`);
621
+ }
622
+ });
453
623
  }
454
624
 
455
625
  async mkdirOperation(path: string, recursive: boolean = false): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
@@ -947,17 +1117,25 @@ export class SessionManager {
947
1117
  throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
948
1118
  }
949
1119
  }
950
-
951
- // Clean up existing session with same name
1120
+
1121
+ // Check if session already exists
952
1122
  const existing = this.sessions.get(options.id);
953
1123
  if (existing) {
954
- existing.destroy();
1124
+ // If the existing session is healthy and ready, reuse it
1125
+ if (existing.isReady()) {
1126
+ console.log(`[SessionManager] Reusing existing session '${options.id}'`);
1127
+ return existing;
1128
+ }
1129
+
1130
+ // If the session exists but is not ready, clean it up and create a new one
1131
+ console.log(`[SessionManager] Destroying unhealthy session '${options.id}' before recreating`);
1132
+ await existing.destroy();
955
1133
  }
956
-
1134
+
957
1135
  // Create new session
958
1136
  const session = new Session(options);
959
1137
  await session.initialize();
960
-
1138
+
961
1139
  this.sessions.set(options.id, session);
962
1140
  console.log(`[SessionManager] Created session '${options.id}'`);
963
1141
  return session;
@@ -973,15 +1151,11 @@ export class SessionManager {
973
1151
 
974
1152
  // Helper to get or create default session - reduces duplication
975
1153
  async getOrCreateDefaultSession(): Promise<Session> {
976
- let defaultSession = this.sessions.get('default');
977
- if (!defaultSession) {
978
- defaultSession = await this.createSession({
979
- id: 'default',
980
- cwd: '/workspace', // Consistent default working directory
981
- isolation: true
982
- });
983
- }
984
- return defaultSession;
1154
+ return await this.createSession({
1155
+ id: 'default',
1156
+ cwd: '/workspace', // Consistent default working directory
1157
+ isolation: true
1158
+ });
985
1159
  }
986
1160
 
987
1161
  async exec(command: string, options?: { cwd?: string }): Promise<ExecResult> {
@@ -1000,7 +1174,7 @@ export class SessionManager {
1000
1174
  return defaultSession.writeFileOperation(path, content, encoding);
1001
1175
  }
1002
1176
 
1003
- async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
1177
+ async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string; encoding?: 'utf-8' | 'base64'; isBinary?: boolean; mimeType?: string; size?: number }> {
1004
1178
  const defaultSession = await this.getOrCreateDefaultSession();
1005
1179
  return defaultSession.readFileOperation(path, encoding);
1006
1180
  }
@@ -1036,4 +1210,4 @@ export class SessionManager {
1036
1210
  }
1037
1211
  this.sessions.clear();
1038
1212
  }
1039
- }
1213
+ }
@@ -28,7 +28,7 @@ export interface ChartData {
28
28
  library?: 'matplotlib' | 'plotly' | 'altair' | 'seaborn' | 'unknown';
29
29
  }
30
30
 
31
- export function processJupyterMessage(msg: any): ExecutionResult | null {
31
+ export function processMessage(msg: any): ExecutionResult | null {
32
32
  const msgType = msg.header?.msg_type || msg.msg_type;
33
33
 
34
34
  switch (msgType) {
@@ -7,12 +7,12 @@
7
7
  "start": "bun run index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@jupyterlab/services": "^7.0.0",
11
- "ws": "^8.16.0",
10
+ "esbuild": "^0.21.5",
12
11
  "uuid": "^9.0.1"
13
12
  },
14
13
  "devDependencies": {
15
- "@types/ws": "^8.5.10",
16
- "@types/uuid": "^9.0.7"
14
+ "@types/node": "^20.0.0",
15
+ "@types/uuid": "^9.0.7",
16
+ "typescript": "^5.3.0"
17
17
  }
18
18
  }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as readline from 'node:readline';
4
+ import * as util from 'node:util';
5
+ import * as vm from 'node:vm';
6
+ import type { RichOutput } from '../../process-pool';
7
+
8
+ const rl = readline.createInterface({
9
+ input: process.stdin,
10
+ output: process.stdout,
11
+ terminal: false
12
+ });
13
+
14
+ const sandbox = {
15
+ console: console,
16
+ process: process,
17
+ require: require,
18
+ Buffer: Buffer,
19
+ setTimeout: setTimeout,
20
+ setInterval: setInterval,
21
+ clearTimeout: clearTimeout,
22
+ clearInterval: clearInterval,
23
+ setImmediate: setImmediate,
24
+ clearImmediate: clearImmediate,
25
+ global: global,
26
+ __dirname: __dirname,
27
+ __filename: __filename
28
+ };
29
+
30
+ const context = vm.createContext(sandbox);
31
+
32
+ console.log(JSON.stringify({ status: "ready" }));
33
+
34
+ rl.on('line', async (line: string) => {
35
+ try {
36
+ const request = JSON.parse(line);
37
+ const { code, executionId } = request;
38
+
39
+ const originalStdoutWrite = process.stdout.write;
40
+ const originalStderrWrite = process.stderr.write;
41
+
42
+ let stdout = '';
43
+ let stderr = '';
44
+
45
+ (process.stdout.write as any) = (chunk: string | Buffer, encoding?: BufferEncoding, callback?: () => void) => {
46
+ stdout += chunk.toString();
47
+ if (callback) callback();
48
+ return true;
49
+ };
50
+
51
+ (process.stderr.write as any) = (chunk: string | Buffer, encoding?: BufferEncoding, callback?: () => void) => {
52
+ stderr += chunk.toString();
53
+ if (callback) callback();
54
+ return true;
55
+ };
56
+
57
+ let result: unknown;
58
+ let success = true;
59
+
60
+ try {
61
+ result = vm.runInContext(code, context, {
62
+ filename: `<execution-${executionId}>`,
63
+ timeout: 30000
64
+ });
65
+
66
+ } catch (error: unknown) {
67
+ const err = error as Error;
68
+ stderr += err.stack || err.toString();
69
+ success = false;
70
+ } finally {
71
+ process.stdout.write = originalStdoutWrite;
72
+ process.stderr.write = originalStderrWrite;
73
+ }
74
+
75
+ const outputs: RichOutput[] = [];
76
+
77
+ if (result !== undefined) {
78
+ if (typeof result === 'object' && result !== null) {
79
+ outputs.push({
80
+ type: 'json',
81
+ data: JSON.stringify(result, null, 2),
82
+ metadata: {}
83
+ });
84
+ } else {
85
+ outputs.push({
86
+ type: 'text',
87
+ data: util.inspect(result, { showHidden: false, depth: null, colors: false }),
88
+ metadata: {}
89
+ });
90
+ }
91
+ }
92
+
93
+ const response = {
94
+ stdout,
95
+ stderr,
96
+ success,
97
+ executionId,
98
+ outputs
99
+ };
100
+
101
+ console.log(JSON.stringify(response));
102
+
103
+ } catch (error: unknown) {
104
+ const err = error as Error;
105
+ console.log(JSON.stringify({
106
+ stdout: '',
107
+ stderr: `Error processing request: ${err.message}`,
108
+ success: false,
109
+ executionId: 'unknown',
110
+ outputs: []
111
+ }));
112
+ }
113
+ });
114
+
115
+ process.on('SIGTERM', () => {
116
+ rl.close();
117
+ process.exit(0);
118
+ });
119
+
120
+ process.on('SIGINT', () => {
121
+ rl.close();
122
+ process.exit(0);
123
+ });