@cloudflare/sandbox 0.2.4 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/Dockerfile +9 -11
  3. package/README.md +69 -7
  4. package/container_src/control-process.ts +784 -0
  5. package/container_src/handler/exec.ts +99 -254
  6. package/container_src/handler/file.ts +179 -837
  7. package/container_src/handler/git.ts +28 -80
  8. package/container_src/handler/process.ts +443 -515
  9. package/container_src/handler/session.ts +92 -0
  10. package/container_src/index.ts +68 -130
  11. package/container_src/isolation.ts +1038 -0
  12. package/container_src/shell-escape.ts +42 -0
  13. package/container_src/types.ts +27 -13
  14. package/dist/{chunk-HHUDRGPY.js → chunk-BEQUGUY4.js} +2 -2
  15. package/dist/{chunk-CKIGERRS.js → chunk-LFLJGISB.js} +240 -264
  16. package/dist/chunk-LFLJGISB.js.map +1 -0
  17. package/dist/{chunk-3CQ6THKA.js → chunk-SMUEY5JR.js} +85 -103
  18. package/dist/chunk-SMUEY5JR.js.map +1 -0
  19. package/dist/{client-Ce40ujDF.d.ts → client-Dny_ro_v.d.ts} +41 -25
  20. package/dist/client.d.ts +1 -1
  21. package/dist/client.js +1 -1
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +8 -9
  24. package/dist/interpreter.d.ts +1 -1
  25. package/dist/jupyter-client.d.ts +1 -1
  26. package/dist/jupyter-client.js +2 -2
  27. package/dist/request-handler.d.ts +1 -1
  28. package/dist/request-handler.js +3 -5
  29. package/dist/sandbox.d.ts +1 -1
  30. package/dist/sandbox.js +3 -5
  31. package/dist/types.d.ts +10 -21
  32. package/dist/types.js +35 -9
  33. package/dist/types.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/client.ts +120 -135
  36. package/src/index.ts +8 -0
  37. package/src/sandbox.ts +290 -331
  38. package/src/types.ts +15 -24
  39. package/dist/chunk-3CQ6THKA.js.map +0 -1
  40. package/dist/chunk-6EWSYSO7.js +0 -46
  41. package/dist/chunk-6EWSYSO7.js.map +0 -1
  42. package/dist/chunk-CKIGERRS.js.map +0 -1
  43. /package/dist/{chunk-HHUDRGPY.js.map → chunk-BEQUGUY4.js.map} +0 -0
@@ -0,0 +1,1038 @@
1
+ /**
2
+ * Process Isolation
3
+ *
4
+ * Implements PID namespace isolation to secure the sandbox environment.
5
+ * Executed commands run in isolated namespaces, preventing them from:
6
+ * - Seeing or killing control plane processes (Jupyter, Bun)
7
+ * - Accessing platform secrets in /proc
8
+ * - Hijacking control plane ports
9
+ *
10
+ * ## Two-Process Architecture
11
+ *
12
+ * Parent Process (Node.js) → Control Process (Node.js) → Isolated Shell (Bash)
13
+ *
14
+ * The control process manages the isolated shell and handles all I/O through
15
+ * temp files instead of stdout/stderr parsing. This approach handles:
16
+ * - Binary data without corruption
17
+ * - Large outputs without buffer issues
18
+ * - Command output that might contain markers
19
+ * - Clean recovery when shell dies
20
+ *
21
+ * ## Why file-based IPC?
22
+ * Initial marker-based parsing (UUID markers in stdout) had too many edge cases.
23
+ * File-based IPC reliably handles any output type.
24
+ *
25
+ * Requires CAP_SYS_ADMIN capability (available in production).
26
+ * Falls back to regular execution in development.
27
+ */
28
+
29
+ import { type ChildProcess, spawn } from 'node:child_process';
30
+ import { randomBytes, randomUUID } from 'node:crypto';
31
+ import * as path from 'node:path';
32
+ import type { ExecEvent, ExecResult } from '../src/types';
33
+ import type { ProcessRecord, ProcessStatus } from './types';
34
+
35
+ // Configuration constants
36
+ const CONFIG = {
37
+ // Timeouts (in milliseconds)
38
+ READY_TIMEOUT_MS: 5000, // 5 seconds for control process to initialize
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
+
44
+ // Default paths
45
+ DEFAULT_CWD: '/workspace',
46
+ TEMP_DIR: '/tmp'
47
+ } as const;
48
+
49
+ // Types
50
+ // Internal execution result from the isolated process
51
+ export interface RawExecResult {
52
+ stdout: string;
53
+ stderr: string;
54
+ exitCode: number;
55
+ }
56
+
57
+ export interface SessionOptions {
58
+ id: string;
59
+ env?: Record<string, string>;
60
+ cwd?: string;
61
+ isolation?: boolean;
62
+ }
63
+
64
+ interface ControlMessage {
65
+ type: 'exec' | 'exec_stream' | 'exit';
66
+ id: string;
67
+ command?: string;
68
+ cwd?: string;
69
+ }
70
+
71
+ interface ControlResponse {
72
+ type: 'result' | 'error' | 'ready' | 'stream_event';
73
+ id: string;
74
+ stdout?: string;
75
+ stderr?: string;
76
+ exitCode?: number;
77
+ error?: string;
78
+ event?: ExecEvent;
79
+ }
80
+
81
+ // Cache the namespace support check
82
+ let namespaceSupport: boolean | null = null;
83
+
84
+ /**
85
+ * Check if PID namespace isolation is available (requires CAP_SYS_ADMIN).
86
+ * Returns true in production, false in typical development environments.
87
+ */
88
+ export function hasNamespaceSupport(): boolean {
89
+ if (namespaceSupport !== null) {
90
+ return namespaceSupport;
91
+ }
92
+
93
+ try {
94
+ // Actually test if unshare works
95
+ const { execSync } = require('node:child_process');
96
+ execSync('unshare --pid --fork --mount-proc true', {
97
+ stdio: 'ignore',
98
+ timeout: 1000
99
+ });
100
+ console.log('[Isolation] Namespace support detected (CAP_SYS_ADMIN available)');
101
+ namespaceSupport = true;
102
+ return true;
103
+ } catch (error) {
104
+ console.log('[Isolation] No namespace support (CAP_SYS_ADMIN not available)');
105
+ namespaceSupport = false;
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Session with isolated command execution.
112
+ * Maintains state across commands within the session.
113
+ */
114
+ export class Session {
115
+ private control: ChildProcess | null = null;
116
+ private ready = false;
117
+ private canIsolate: boolean;
118
+ private pendingCallbacks = new Map<string, {
119
+ resolve: (result: RawExecResult) => void;
120
+ reject: (error: Error) => void;
121
+ timeout: NodeJS.Timeout;
122
+ }>();
123
+ private processes = new Map<string, ProcessRecord>(); // Session-specific processes
124
+
125
+ constructor(private options: SessionOptions) {
126
+ this.canIsolate = (options.isolation === true) && hasNamespaceSupport();
127
+ if (options.isolation === true && !this.canIsolate) {
128
+ console.log(`[Session] Isolation requested for '${options.id}' but not available`);
129
+ }
130
+ }
131
+
132
+ async initialize(): Promise<void> {
133
+ // Use the proper TypeScript control process file
134
+ const controlProcessPath = path.join(__dirname, 'control-process.js');
135
+
136
+ // Start control process with configuration via environment variables
137
+ this.control = spawn('node', [controlProcessPath], {
138
+ stdio: ['pipe', 'pipe', 'pipe'],
139
+ env: {
140
+ ...process.env,
141
+ SESSION_ID: this.options.id,
142
+ SESSION_CWD: this.options.cwd || CONFIG.DEFAULT_CWD,
143
+ SESSION_ISOLATED: this.canIsolate ? '1' : '0',
144
+ COMMAND_TIMEOUT_MS: String(CONFIG.COMMAND_TIMEOUT_MS),
145
+ CLEANUP_INTERVAL_MS: String(CONFIG.CLEANUP_INTERVAL_MS),
146
+ TEMP_FILE_MAX_AGE_MS: String(CONFIG.TEMP_FILE_MAX_AGE_MS),
147
+ TEMP_DIR: CONFIG.TEMP_DIR,
148
+ ...this.options.env
149
+ }
150
+ });
151
+
152
+ // Handle control process output
153
+ this.control.stdout?.on('data', (data: Buffer) => {
154
+ const lines = data.toString().split('\n');
155
+ for (const line of lines) {
156
+ if (!line.trim()) continue;
157
+ try {
158
+ const msg: ControlResponse = JSON.parse(line);
159
+ this.handleControlMessage(msg);
160
+ } catch (e) {
161
+ console.error(`[Session] Failed to parse control message: ${line}`);
162
+ }
163
+ }
164
+ });
165
+
166
+ // Handle control process errors
167
+ this.control.stderr?.on('data', (data: Buffer) => {
168
+ console.error(`[Session] Control stderr for '${this.options.id}': ${data.toString()}`);
169
+ });
170
+
171
+ this.control.on('error', (error) => {
172
+ console.error(`[Session] Control process error for '${this.options.id}':`, error);
173
+ this.cleanup(error);
174
+ });
175
+
176
+ this.control.on('exit', (code) => {
177
+ console.log(`[Session] Control process exited for '${this.options.id}' with code ${code}`);
178
+ this.cleanup(new Error(`Control process exited with code ${code}`));
179
+ });
180
+
181
+ // Wait for ready signal
182
+ await this.waitForReady();
183
+
184
+ console.log(`[Session] Session '${this.options.id}' initialized successfully`);
185
+ }
186
+
187
+
188
+ private waitForReady(): Promise<void> {
189
+ return new Promise((resolve, reject) => {
190
+ const timeout = setTimeout(() => {
191
+ reject(new Error('Control process initialization timeout'));
192
+ }, CONFIG.READY_TIMEOUT_MS);
193
+
194
+ const checkReady = (msg: ControlResponse) => {
195
+ if (msg.type === 'ready' && msg.id === 'init') {
196
+ clearTimeout(timeout);
197
+ this.ready = true;
198
+ resolve();
199
+ }
200
+ };
201
+
202
+ // Temporarily store the ready handler
203
+ const originalHandler = this.handleControlMessage;
204
+ this.handleControlMessage = (msg) => {
205
+ checkReady(msg);
206
+ originalHandler.call(this, msg);
207
+ };
208
+ });
209
+ }
210
+
211
+ private handleControlMessage(msg: ControlResponse): void {
212
+ if (msg.type === 'ready' && msg.id === 'init') {
213
+ this.ready = true;
214
+ return;
215
+ }
216
+
217
+ const callback = this.pendingCallbacks.get(msg.id);
218
+ if (!callback) return;
219
+
220
+ clearTimeout(callback.timeout);
221
+ this.pendingCallbacks.delete(msg.id);
222
+
223
+ if (msg.type === 'error') {
224
+ callback.reject(new Error(msg.error || 'Unknown error'));
225
+ } else if (msg.type === 'result') {
226
+ callback.resolve({
227
+ stdout: msg.stdout || '',
228
+ stderr: msg.stderr || '',
229
+ exitCode: msg.exitCode || 0
230
+ });
231
+ }
232
+ }
233
+
234
+ private cleanup(error?: Error): void {
235
+ // Reject all pending callbacks
236
+ for (const [id, callback] of this.pendingCallbacks) {
237
+ clearTimeout(callback.timeout);
238
+ callback.reject(error || new Error('Session terminated'));
239
+ }
240
+ this.pendingCallbacks.clear();
241
+
242
+ // Kill control process if still running
243
+ if (this.control && !this.control.killed) {
244
+ this.control.kill('SIGTERM');
245
+ }
246
+
247
+ this.control = null;
248
+ this.ready = false;
249
+ }
250
+
251
+ async exec(command: string, options?: { cwd?: string }): Promise<ExecResult> {
252
+ if (!this.ready || !this.control) {
253
+ throw new Error(`Session '${this.options.id}' not initialized`);
254
+ }
255
+
256
+ // Validate cwd if provided - must be absolute path
257
+ if (options?.cwd) {
258
+ if (!options.cwd.startsWith('/')) {
259
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
260
+ }
261
+ }
262
+
263
+ const id = randomUUID();
264
+ const startTime = Date.now();
265
+
266
+ return new Promise<RawExecResult>((resolve, reject) => {
267
+ // Set up timeout
268
+ const timeout = setTimeout(() => {
269
+ this.pendingCallbacks.delete(id);
270
+ reject(new Error(`Command timeout: ${command}`));
271
+ }, CONFIG.COMMAND_TIMEOUT_MS);
272
+
273
+ // Store callback
274
+ this.pendingCallbacks.set(id, { resolve, reject, timeout });
275
+
276
+ // Send command to control process with cwd override
277
+ const msg: ControlMessage = {
278
+ type: 'exec',
279
+ id,
280
+ command,
281
+ cwd: options?.cwd // Pass through cwd override if provided
282
+ };
283
+ this.control!.stdin?.write(`${JSON.stringify(msg)}\n`);
284
+ }).then(raw => ({
285
+ ...raw,
286
+ success: raw.exitCode === 0,
287
+ command,
288
+ duration: Date.now() - startTime,
289
+ timestamp: new Date().toISOString()
290
+ }));
291
+ }
292
+
293
+ async *execStream(command: string, options?: { cwd?: string }): AsyncGenerator<ExecEvent> {
294
+ if (!this.ready || !this.control) {
295
+ throw new Error(`Session '${this.options.id}' not initialized`);
296
+ }
297
+
298
+ // Validate cwd if provided - must be absolute path
299
+ if (options?.cwd) {
300
+ if (!options.cwd.startsWith('/')) {
301
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
302
+ }
303
+ }
304
+
305
+ const id = randomUUID();
306
+ const timestamp = new Date().toISOString();
307
+
308
+ // Yield start event
309
+ yield {
310
+ type: 'start',
311
+ timestamp,
312
+ command,
313
+ };
314
+
315
+ // Set up streaming callback handling
316
+ const streamingCallbacks = new Map<string, {
317
+ resolve: () => void;
318
+ reject: (error: Error) => void;
319
+ events: ExecEvent[];
320
+ complete: boolean;
321
+ }>();
322
+
323
+ // Temporarily override message handling to capture streaming events
324
+ const originalHandler = this.control!.stdout?.listeners('data')[0];
325
+
326
+ const streamHandler = (data: Buffer) => {
327
+ const lines = data.toString().split('\n');
328
+ for (const line of lines) {
329
+ if (!line.trim()) continue;
330
+ try {
331
+ const msg: any = JSON.parse(line);
332
+ if (msg.type === 'stream_event' && msg.id === id) {
333
+ const callback = streamingCallbacks.get(id);
334
+ if (callback) {
335
+ callback.events.push(msg.event);
336
+ if (msg.event.type === 'complete' || msg.event.type === 'error') {
337
+ callback.complete = true;
338
+ callback.resolve();
339
+ }
340
+ }
341
+ } else {
342
+ // Pass through other messages to original handler
343
+ originalHandler?.(data);
344
+ }
345
+ } catch (e) {
346
+ console.error(`[Session] Failed to parse stream message: ${line}`);
347
+ }
348
+ }
349
+ };
350
+
351
+ try {
352
+ // Set up promise for completion
353
+ const streamPromise = new Promise<void>((resolve, reject) => {
354
+ streamingCallbacks.set(id, {
355
+ resolve,
356
+ reject,
357
+ events: [],
358
+ complete: false
359
+ });
360
+
361
+ // Set up timeout
362
+ setTimeout(() => {
363
+ const callback = streamingCallbacks.get(id);
364
+ if (callback && !callback.complete) {
365
+ streamingCallbacks.delete(id);
366
+ reject(new Error(`Stream timeout: ${command}`));
367
+ }
368
+ }, CONFIG.COMMAND_TIMEOUT_MS);
369
+ });
370
+
371
+ // Replace stdout handler temporarily
372
+ this.control!.stdout?.off('data', originalHandler as any);
373
+ this.control!.stdout?.on('data', streamHandler);
374
+
375
+ // Send streaming exec command to control process
376
+ const msg: ControlMessage = {
377
+ type: 'exec_stream',
378
+ id,
379
+ command,
380
+ cwd: options?.cwd // Pass through cwd override if provided
381
+ };
382
+ this.control!.stdin?.write(`${JSON.stringify(msg)}\n`);
383
+
384
+ // Yield events as they come in
385
+ const callback = streamingCallbacks.get(id)!;
386
+ let lastEventIndex = 0;
387
+
388
+ while (!callback.complete) {
389
+ // Yield any new events
390
+ while (lastEventIndex < callback.events.length) {
391
+ yield callback.events[lastEventIndex];
392
+ lastEventIndex++;
393
+ }
394
+
395
+ // Wait a bit before checking again
396
+ await new Promise(resolve => setTimeout(resolve, 10));
397
+ }
398
+
399
+ // Yield any remaining events
400
+ while (lastEventIndex < callback.events.length) {
401
+ yield callback.events[lastEventIndex];
402
+ lastEventIndex++;
403
+ }
404
+
405
+ await streamPromise;
406
+
407
+ } catch (error) {
408
+ yield {
409
+ type: 'error',
410
+ timestamp: new Date().toISOString(),
411
+ command,
412
+ error: error instanceof Error ? error.message : String(error),
413
+ };
414
+ } finally {
415
+ // Restore original handler
416
+ this.control!.stdout?.off('data', streamHandler);
417
+ if (originalHandler) {
418
+ this.control!.stdout?.on('data', originalHandler as any);
419
+ }
420
+ streamingCallbacks.delete(id);
421
+ }
422
+ }
423
+
424
+ // File Operations - Execute as shell commands to inherit session context
425
+ async writeFileOperation(path: string, content: string, encoding: string = 'utf-8'): Promise<{ success: boolean; exitCode: number; path: string }> {
426
+ // Create parent directory if needed, then write file using heredoc
427
+ // Note: The quoted heredoc delimiter 'SANDBOX_EOF' prevents variable expansion
428
+ // and treats the content literally, so no escaping is required
429
+ const command = `mkdir -p "$(dirname "${path}")" && cat > "${path}" << 'SANDBOX_EOF'
430
+ ${content}
431
+ SANDBOX_EOF`;
432
+
433
+ const result = await this.exec(command);
434
+
435
+ return {
436
+ success: result.exitCode === 0,
437
+ exitCode: result.exitCode,
438
+ path
439
+ };
440
+ }
441
+
442
+ async readFileOperation(path: string, encoding: string = 'utf-8'): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
443
+ const command = `cat "${path}"`;
444
+ const result = await this.exec(command);
445
+
446
+ return {
447
+ success: result.exitCode === 0,
448
+ exitCode: result.exitCode,
449
+ content: result.stdout,
450
+ path
451
+ };
452
+ }
453
+
454
+ async mkdirOperation(path: string, recursive: boolean = false): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
455
+ const command = recursive ? `mkdir -p "${path}"` : `mkdir "${path}"`;
456
+ const result = await this.exec(command);
457
+
458
+ return {
459
+ success: result.exitCode === 0,
460
+ exitCode: result.exitCode,
461
+ path,
462
+ recursive
463
+ };
464
+ }
465
+
466
+ async deleteFileOperation(path: string): Promise<{ success: boolean; exitCode: number; path: string }> {
467
+ const command = `rm "${path}"`;
468
+ const result = await this.exec(command);
469
+
470
+ return {
471
+ success: result.exitCode === 0,
472
+ exitCode: result.exitCode,
473
+ path
474
+ };
475
+ }
476
+
477
+ async renameFileOperation(oldPath: string, newPath: string): Promise<{ success: boolean; exitCode: number; oldPath: string; newPath: string }> {
478
+ const command = `mv "${oldPath}" "${newPath}"`;
479
+ const result = await this.exec(command);
480
+
481
+ return {
482
+ success: result.exitCode === 0,
483
+ exitCode: result.exitCode,
484
+ oldPath,
485
+ newPath
486
+ };
487
+ }
488
+
489
+ async moveFileOperation(sourcePath: string, destinationPath: string): Promise<{ success: boolean; exitCode: number; sourcePath: string; destinationPath: string }> {
490
+ const command = `mv "${sourcePath}" "${destinationPath}"`;
491
+ const result = await this.exec(command);
492
+
493
+ return {
494
+ success: result.exitCode === 0,
495
+ exitCode: result.exitCode,
496
+ sourcePath,
497
+ destinationPath
498
+ };
499
+ }
500
+
501
+ async listFilesOperation(path: string, options?: { recursive?: boolean; includeHidden?: boolean }): Promise<{ success: boolean; exitCode: number; files: any[]; path: string }> {
502
+ // Build ls command with appropriate flags
503
+ let lsFlags = '-la'; // Long format with all files (including hidden)
504
+ if (!options?.includeHidden) {
505
+ lsFlags = '-l'; // Long format without hidden files
506
+ }
507
+ if (options?.recursive) {
508
+ lsFlags += 'R'; // Recursive
509
+ }
510
+
511
+ const command = `ls ${lsFlags} "${path}" 2>/dev/null || echo "DIRECTORY_NOT_FOUND"`;
512
+ const result = await this.exec(command);
513
+
514
+ if (result.stdout.includes('DIRECTORY_NOT_FOUND')) {
515
+ return {
516
+ success: false,
517
+ exitCode: 1,
518
+ files: [],
519
+ path
520
+ };
521
+ }
522
+
523
+ // Parse ls output into structured file data
524
+ const files = this.parseLsOutput(result.stdout, path);
525
+
526
+ return {
527
+ success: result.exitCode === 0,
528
+ exitCode: result.exitCode,
529
+ files,
530
+ path
531
+ };
532
+ }
533
+
534
+ private parseLsOutput(lsOutput: string, basePath: string): any[] {
535
+ const lines = lsOutput.split('\n').filter(line => line.trim());
536
+ const files: any[] = [];
537
+
538
+ for (const line of lines) {
539
+ // Skip total line and empty lines
540
+ if (line.startsWith('total') || !line.trim()) continue;
541
+
542
+ // Parse ls -l format: permissions, links, user, group, size, date, name
543
+ const match = line.match(/^([-dlrwx]+)\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
544
+ if (!match) continue;
545
+
546
+ const [, permissions, size, dateStr, name] = match;
547
+
548
+ // Determine file type from first character of permissions
549
+ let type: 'file' | 'directory' | 'symlink' | 'other' = 'file';
550
+ if (permissions.startsWith('d')) type = 'directory';
551
+ else if (permissions.startsWith('l')) type = 'symlink';
552
+ else if (!permissions.startsWith('-')) type = 'other';
553
+
554
+ // Extract permission booleans for user (first 3 chars after type indicator)
555
+ const userPerms = permissions.substring(1, 4);
556
+
557
+ files.push({
558
+ name,
559
+ absolutePath: `${basePath}/${name}`,
560
+ relativePath: name,
561
+ type,
562
+ size: parseInt(size),
563
+ modifiedAt: dateStr, // Simplified date parsing
564
+ mode: permissions,
565
+ permissions: {
566
+ readable: userPerms[0] === 'r',
567
+ writable: userPerms[1] === 'w',
568
+ executable: userPerms[2] === 'x'
569
+ }
570
+ });
571
+ }
572
+
573
+ return files;
574
+ }
575
+
576
+ // Process Management Methods
577
+
578
+ async startProcess(command: string, options?: {
579
+ processId?: string;
580
+ timeout?: number;
581
+ env?: Record<string, string>;
582
+ cwd?: string;
583
+ encoding?: string;
584
+ autoCleanup?: boolean;
585
+ }): Promise<ProcessRecord> {
586
+ // Validate cwd if provided - must be absolute path
587
+ if (options?.cwd) {
588
+ if (!options.cwd.startsWith('/')) {
589
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
590
+ }
591
+ }
592
+
593
+ const processId = options?.processId || `proc_${Date.now()}_${randomBytes(6).toString('hex')}`;
594
+
595
+ // Check if process ID already exists in this session
596
+ if (this.processes.has(processId)) {
597
+ throw new Error(`Process already exists in session: ${processId}`);
598
+ }
599
+
600
+ console.log(`[Session ${this.options.id}] Starting process: ${command} (ID: ${processId})`);
601
+
602
+ // Create separate temp files for stdout and stderr
603
+ const stdoutFile = `/tmp/proc_${processId}.stdout`;
604
+ const stderrFile = `/tmp/proc_${processId}.stderr`;
605
+
606
+ // Create process record
607
+ const processRecord: ProcessRecord = {
608
+ id: processId,
609
+ command,
610
+ status: 'starting',
611
+ startTime: new Date(),
612
+ stdout: '',
613
+ stderr: '',
614
+ outputListeners: new Set(),
615
+ statusListeners: new Set()
616
+ };
617
+
618
+ this.processes.set(processId, processRecord);
619
+
620
+ // Start the process in background with nohup
621
+ // Keep stdout and stderr separate
622
+ const backgroundCommand = `nohup ${command} > ${stdoutFile} 2> ${stderrFile} & echo $!`;
623
+
624
+ try {
625
+ // Execute the background command and get PID directly
626
+ const result = await this.exec(backgroundCommand, { cwd: options?.cwd });
627
+ const pid = parseInt(result.stdout.trim(), 10);
628
+
629
+ if (Number.isNaN(pid)) {
630
+ throw new Error(`Failed to get process PID from output: ${result.stdout}`);
631
+ }
632
+
633
+ processRecord.pid = pid;
634
+ processRecord.status = 'running';
635
+
636
+ // Store the output file paths for later retrieval
637
+ processRecord.stdoutFile = stdoutFile;
638
+ processRecord.stderrFile = stderrFile;
639
+
640
+ console.log(`[Session ${this.options.id}] Process ${processId} started with PID ${pid}`);
641
+
642
+ } catch (error) {
643
+ processRecord.status = 'error';
644
+ processRecord.endTime = new Date();
645
+ processRecord.stderr = `Failed to start process: ${error instanceof Error ? error.message : String(error)}`;
646
+
647
+ // Notify status listeners
648
+ for (const listener of processRecord.statusListeners) {
649
+ listener('error');
650
+ }
651
+
652
+ // Clean up temp files
653
+ this.exec(`rm -f ${stdoutFile} ${stderrFile}`).catch(() => {});
654
+
655
+ throw error;
656
+ }
657
+
658
+ return processRecord;
659
+ }
660
+
661
+ // Check and update process status on-demand
662
+ private async updateProcessStatus(processRecord: ProcessRecord): Promise<void> {
663
+ if (!processRecord.pid || processRecord.status !== 'running') {
664
+ return; // Nothing to check
665
+ }
666
+
667
+ try {
668
+ // Check if process is still running (kill -0 just checks, doesn't actually kill)
669
+ const checkResult = await this.exec(`kill -0 ${processRecord.pid} 2>/dev/null && echo "running" || echo "stopped"`);
670
+ const isRunning = checkResult.stdout.trim() === 'running';
671
+
672
+ if (!isRunning) {
673
+ // Process has stopped
674
+ processRecord.status = 'completed';
675
+ processRecord.endTime = new Date();
676
+
677
+ // Try to get exit status from wait (may not work if process already reaped)
678
+ try {
679
+ const waitResult = await this.exec(`wait ${processRecord.pid} 2>/dev/null; echo $?`);
680
+ const exitCode = parseInt(waitResult.stdout.trim(), 10);
681
+ if (!Number.isNaN(exitCode)) {
682
+ processRecord.exitCode = exitCode;
683
+ if (exitCode !== 0) {
684
+ processRecord.status = 'failed';
685
+ }
686
+ }
687
+ } catch {
688
+ // Can't get exit code, that's okay
689
+ }
690
+
691
+ // Read final output if not already cached
692
+ if ((processRecord.stdoutFile || processRecord.stderrFile) && (!processRecord.stdout && !processRecord.stderr)) {
693
+ await this.updateProcessLogs(processRecord);
694
+ }
695
+
696
+ // Notify status listeners
697
+ for (const listener of processRecord.statusListeners) {
698
+ listener(processRecord.status);
699
+ }
700
+
701
+ console.log(`[Session ${this.options.id}] Process ${processRecord.id} completed with status: ${processRecord.status}`);
702
+ }
703
+ } catch (error) {
704
+ console.error(`[Session ${this.options.id}] Error checking process ${processRecord.id} status:`, error);
705
+ }
706
+ }
707
+
708
+ // Update process logs from output files
709
+ private async updateProcessLogs(processRecord: ProcessRecord): Promise<void> {
710
+ if (!processRecord.stdoutFile && !processRecord.stderrFile) {
711
+ return;
712
+ }
713
+
714
+ try {
715
+ // Read stdout
716
+ if (processRecord.stdoutFile) {
717
+ const result = await this.exec(`cat ${processRecord.stdoutFile} 2>/dev/null || true`);
718
+ const newStdout = result.stdout;
719
+
720
+ // Check if there's new stdout
721
+ if (newStdout.length > processRecord.stdout.length) {
722
+ const delta = newStdout.substring(processRecord.stdout.length);
723
+ processRecord.stdout = newStdout;
724
+
725
+ // Notify output listeners if any
726
+ for (const listener of processRecord.outputListeners) {
727
+ listener('stdout', delta);
728
+ }
729
+ }
730
+ }
731
+
732
+ // Read stderr
733
+ if (processRecord.stderrFile) {
734
+ const result = await this.exec(`cat ${processRecord.stderrFile} 2>/dev/null || true`);
735
+ const newStderr = result.stdout; // Note: reading stderr file content from result.stdout
736
+
737
+ // Check if there's new stderr
738
+ if (newStderr.length > processRecord.stderr.length) {
739
+ const delta = newStderr.substring(processRecord.stderr.length);
740
+ processRecord.stderr = newStderr;
741
+
742
+ // Notify output listeners if any
743
+ for (const listener of processRecord.outputListeners) {
744
+ listener('stderr', delta);
745
+ }
746
+ }
747
+ }
748
+ } catch (error) {
749
+ console.error(`[Session ${this.options.id}] Error reading process ${processRecord.id} logs:`, error);
750
+ }
751
+ }
752
+
753
+ async getProcess(processId: string): Promise<ProcessRecord | undefined> {
754
+ const process = this.processes.get(processId);
755
+ if (process) {
756
+ // Update status before returning
757
+ await this.updateProcessStatus(process);
758
+ }
759
+ return process;
760
+ }
761
+
762
+ async listProcesses(): Promise<ProcessRecord[]> {
763
+ const processes = Array.from(this.processes.values());
764
+
765
+ // Update status for all running processes
766
+ for (const process of processes) {
767
+ if (process.status === 'running') {
768
+ await this.updateProcessStatus(process);
769
+ }
770
+ }
771
+
772
+ return processes;
773
+ }
774
+
775
+ async killProcess(processId: string): Promise<boolean> {
776
+ const process = this.processes.get(processId);
777
+ if (!process) {
778
+ return false;
779
+ }
780
+
781
+ // Stop monitoring first
782
+ this.stopProcessMonitoring(process);
783
+
784
+ // Only try to kill if process is running
785
+ if (process.pid && (process.status === 'running' || process.status === 'starting')) {
786
+ try {
787
+ // Send SIGTERM to the process
788
+ await this.exec(`kill ${process.pid} 2>/dev/null || true`);
789
+ console.log(`[Session ${this.options.id}] Sent SIGTERM to process ${processId} (PID: ${process.pid})`);
790
+
791
+ // Give it a moment to terminate gracefully (500ms), then force kill if needed
792
+ await new Promise(resolve => setTimeout(resolve, 500));
793
+
794
+ // Check if still running and force kill if needed
795
+ const checkResult = await this.exec(`kill -0 ${process.pid} 2>/dev/null && echo "running" || echo "stopped"`);
796
+ if (checkResult.stdout.trim() === 'running') {
797
+ await this.exec(`kill -9 ${process.pid} 2>/dev/null || true`);
798
+ console.log(`[Session ${this.options.id}] Force killed process ${processId} (PID: ${process.pid})`);
799
+ }
800
+ } catch (error) {
801
+ console.error(`[Session ${this.options.id}] Error killing process ${processId}:`, error);
802
+ }
803
+
804
+ process.status = 'killed';
805
+ process.endTime = new Date();
806
+
807
+ // Read final output before cleanup
808
+ if (process.stdoutFile || process.stderrFile) {
809
+ await this.updateProcessLogs(process);
810
+ }
811
+
812
+ // Notify status listeners
813
+ for (const listener of process.statusListeners) {
814
+ listener('killed');
815
+ }
816
+
817
+ // Clean up temp files
818
+ if (process.stdoutFile || process.stderrFile) {
819
+ this.exec(`rm -f ${process.stdoutFile} ${process.stderrFile}`).catch(() => {});
820
+ }
821
+
822
+ return true;
823
+ }
824
+
825
+ // Process not running, just update status
826
+ if (process.status === 'running' || process.status === 'starting') {
827
+ process.status = 'killed';
828
+ process.endTime = new Date();
829
+
830
+ // Notify status listeners
831
+ for (const listener of process.statusListeners) {
832
+ listener('killed');
833
+ }
834
+ }
835
+
836
+ return true;
837
+ }
838
+
839
+ async killAllProcesses(): Promise<number> {
840
+ let killedCount = 0;
841
+ for (const [id, _] of this.processes) {
842
+ if (await this.killProcess(id)) {
843
+ killedCount++;
844
+ }
845
+ }
846
+ return killedCount;
847
+ }
848
+
849
+ // Get process logs (updates from files if needed)
850
+ async getProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }> {
851
+ const process = this.processes.get(processId);
852
+ if (!process) {
853
+ throw new Error(`Process not found: ${processId}`);
854
+ }
855
+
856
+ // Update logs from files
857
+ if (process.stdoutFile || process.stderrFile) {
858
+ await this.updateProcessLogs(process);
859
+ }
860
+
861
+ // Return current logs
862
+ return {
863
+ stdout: process.stdout,
864
+ stderr: process.stderr
865
+ };
866
+ }
867
+
868
+ // Start monitoring a process for output changes
869
+ startProcessMonitoring(processRecord: ProcessRecord): void {
870
+ // Don't monitor if already monitoring or process not running
871
+ if (processRecord.monitoringInterval || processRecord.status !== 'running') {
872
+ return;
873
+ }
874
+
875
+ console.log(`[Session ${this.options.id}] Starting monitoring for process ${processRecord.id}`);
876
+
877
+ // Poll every 100ms for near real-time updates
878
+ processRecord.monitoringInterval = setInterval(async () => {
879
+ // Stop monitoring if no listeners or process not running
880
+ if (processRecord.outputListeners.size === 0 || processRecord.status !== 'running') {
881
+ this.stopProcessMonitoring(processRecord);
882
+ return;
883
+ }
884
+
885
+ // Update logs from temp files (this will notify listeners if there's new content)
886
+ await this.updateProcessLogs(processRecord);
887
+
888
+ // Also check if process is still running
889
+ await this.updateProcessStatus(processRecord);
890
+ }, 100);
891
+ }
892
+
893
+ // Stop monitoring a process
894
+ stopProcessMonitoring(processRecord: ProcessRecord): void {
895
+ if (processRecord.monitoringInterval) {
896
+ console.log(`[Session ${this.options.id}] Stopping monitoring for process ${processRecord.id}`);
897
+ clearInterval(processRecord.monitoringInterval);
898
+ processRecord.monitoringInterval = undefined;
899
+ }
900
+ }
901
+
902
+ async destroy(): Promise<void> {
903
+ // Stop all monitoring intervals first
904
+ for (const process of this.processes.values()) {
905
+ this.stopProcessMonitoring(process);
906
+ }
907
+
908
+ // Kill all processes
909
+ await this.killAllProcesses();
910
+
911
+ // Clean up all temp files
912
+ for (const [id, process] of this.processes) {
913
+ if (process.stdoutFile || process.stderrFile) {
914
+ await this.exec(`rm -f ${process.stdoutFile} ${process.stderrFile}`).catch(() => {});
915
+ }
916
+ }
917
+
918
+ if (this.control) {
919
+ // Send exit command
920
+ const msg: ControlMessage = { type: 'exit', id: 'destroy' };
921
+ this.control.stdin?.write(`${JSON.stringify(msg)}\n`);
922
+
923
+ // Give it a moment to exit cleanly
924
+ setTimeout(() => {
925
+ if (this.control && !this.control.killed) {
926
+ this.control.kill('SIGTERM');
927
+ }
928
+ }, 100);
929
+
930
+ this.cleanup();
931
+ }
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Manages isolated sessions for command execution.
937
+ * Each session maintains its own state (pwd, env vars, processes).
938
+ */
939
+ export class SessionManager {
940
+ private sessions = new Map<string, Session>();
941
+
942
+ async createSession(options: SessionOptions): Promise<Session> {
943
+ // Validate cwd if provided - must be absolute path
944
+ if (options.cwd) {
945
+ if (!options.cwd.startsWith('/')) {
946
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
947
+ }
948
+ }
949
+
950
+ // Clean up existing session with same name
951
+ const existing = this.sessions.get(options.id);
952
+ if (existing) {
953
+ existing.destroy();
954
+ }
955
+
956
+ // Create new session
957
+ const session = new Session(options);
958
+ await session.initialize();
959
+
960
+ this.sessions.set(options.id, session);
961
+ console.log(`[SessionManager] Created session '${options.id}'`);
962
+ return session;
963
+ }
964
+
965
+ getSession(id: string): Session | undefined {
966
+ return this.sessions.get(id);
967
+ }
968
+
969
+ listSessions(): string[] {
970
+ return Array.from(this.sessions.keys());
971
+ }
972
+
973
+ // Helper to get or create default session - reduces duplication
974
+ async getOrCreateDefaultSession(): Promise<Session> {
975
+ let defaultSession = this.sessions.get('default');
976
+ if (!defaultSession) {
977
+ defaultSession = await this.createSession({
978
+ id: 'default',
979
+ cwd: '/workspace', // Consistent default working directory
980
+ isolation: true
981
+ });
982
+ }
983
+ return defaultSession;
984
+ }
985
+
986
+ async exec(command: string, options?: { cwd?: string }): Promise<ExecResult> {
987
+ const defaultSession = await this.getOrCreateDefaultSession();
988
+ return defaultSession.exec(command, options);
989
+ }
990
+
991
+ async *execStream(command: string, options?: { cwd?: string }): AsyncGenerator<ExecEvent> {
992
+ const defaultSession = await this.getOrCreateDefaultSession();
993
+ yield* defaultSession.execStream(command, options);
994
+ }
995
+
996
+ // File Operations - Clean method names following existing pattern
997
+ async writeFile(path: string, content: string, encoding?: string): Promise<{ success: boolean; exitCode: number; path: string }> {
998
+ const defaultSession = await this.getOrCreateDefaultSession();
999
+ return defaultSession.writeFileOperation(path, content, encoding);
1000
+ }
1001
+
1002
+ async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
1003
+ const defaultSession = await this.getOrCreateDefaultSession();
1004
+ return defaultSession.readFileOperation(path, encoding);
1005
+ }
1006
+
1007
+ async mkdir(path: string, recursive?: boolean): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
1008
+ const defaultSession = await this.getOrCreateDefaultSession();
1009
+ return defaultSession.mkdirOperation(path, recursive);
1010
+ }
1011
+
1012
+ async deleteFile(path: string): Promise<{ success: boolean; exitCode: number; path: string }> {
1013
+ const defaultSession = await this.getOrCreateDefaultSession();
1014
+ return defaultSession.deleteFileOperation(path);
1015
+ }
1016
+
1017
+ async renameFile(oldPath: string, newPath: string): Promise<{ success: boolean; exitCode: number; oldPath: string; newPath: string }> {
1018
+ const defaultSession = await this.getOrCreateDefaultSession();
1019
+ return defaultSession.renameFileOperation(oldPath, newPath);
1020
+ }
1021
+
1022
+ async moveFile(sourcePath: string, destinationPath: string): Promise<{ success: boolean; exitCode: number; sourcePath: string; destinationPath: string }> {
1023
+ const defaultSession = await this.getOrCreateDefaultSession();
1024
+ return defaultSession.moveFileOperation(sourcePath, destinationPath);
1025
+ }
1026
+
1027
+ async listFiles(path: string, options?: { recursive?: boolean; includeHidden?: boolean }): Promise<{ success: boolean; exitCode: number; files: any[]; path: string }> {
1028
+ const defaultSession = await this.getOrCreateDefaultSession();
1029
+ return defaultSession.listFilesOperation(path, options);
1030
+ }
1031
+
1032
+ async destroyAll(): Promise<void> {
1033
+ for (const session of this.sessions.values()) {
1034
+ await session.destroy();
1035
+ }
1036
+ this.sessions.clear();
1037
+ }
1038
+ }