@cloudflare/sandbox 0.2.4 → 0.3.0

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 +63 -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 +1039 -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-GTGWAEED.js} +237 -264
  16. package/dist/chunk-GTGWAEED.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 +1 -1
  35. package/src/client.ts +120 -135
  36. package/src/index.ts +8 -0
  37. package/src/sandbox.ts +286 -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,1039 @@
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
+ COMMAND_TIMEOUT_MS: 30000, // 30 seconds for command execution
39
+ 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
+ SHUTDOWN_GRACE_PERIOD_MS: 500, // Grace period for cleanup on shutdown
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
+ // Escape content for safe heredoc usage
427
+ const safeContent = content.replace(/'/g, "'\\''");
428
+
429
+ // Create parent directory if needed, then write file using heredoc
430
+ const command = `mkdir -p "$(dirname "${path}")" && cat > "${path}" << 'SANDBOX_EOF'
431
+ ${safeContent}
432
+ SANDBOX_EOF`;
433
+
434
+ const result = await this.exec(command);
435
+
436
+ return {
437
+ success: result.exitCode === 0,
438
+ exitCode: result.exitCode,
439
+ path
440
+ };
441
+ }
442
+
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
+
447
+ return {
448
+ success: result.exitCode === 0,
449
+ exitCode: result.exitCode,
450
+ content: result.stdout,
451
+ path
452
+ };
453
+ }
454
+
455
+ async mkdirOperation(path: string, recursive: boolean = false): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
456
+ const command = recursive ? `mkdir -p "${path}"` : `mkdir "${path}"`;
457
+ const result = await this.exec(command);
458
+
459
+ return {
460
+ success: result.exitCode === 0,
461
+ exitCode: result.exitCode,
462
+ path,
463
+ recursive
464
+ };
465
+ }
466
+
467
+ async deleteFileOperation(path: string): Promise<{ success: boolean; exitCode: number; path: string }> {
468
+ const command = `rm "${path}"`;
469
+ const result = await this.exec(command);
470
+
471
+ return {
472
+ success: result.exitCode === 0,
473
+ exitCode: result.exitCode,
474
+ path
475
+ };
476
+ }
477
+
478
+ async renameFileOperation(oldPath: string, newPath: string): Promise<{ success: boolean; exitCode: number; oldPath: string; newPath: string }> {
479
+ const command = `mv "${oldPath}" "${newPath}"`;
480
+ const result = await this.exec(command);
481
+
482
+ return {
483
+ success: result.exitCode === 0,
484
+ exitCode: result.exitCode,
485
+ oldPath,
486
+ newPath
487
+ };
488
+ }
489
+
490
+ async moveFileOperation(sourcePath: string, destinationPath: string): Promise<{ success: boolean; exitCode: number; sourcePath: string; destinationPath: string }> {
491
+ const command = `mv "${sourcePath}" "${destinationPath}"`;
492
+ const result = await this.exec(command);
493
+
494
+ return {
495
+ success: result.exitCode === 0,
496
+ exitCode: result.exitCode,
497
+ sourcePath,
498
+ destinationPath
499
+ };
500
+ }
501
+
502
+ async listFilesOperation(path: string, options?: { recursive?: boolean; includeHidden?: boolean }): Promise<{ success: boolean; exitCode: number; files: any[]; path: string }> {
503
+ // Build ls command with appropriate flags
504
+ let lsFlags = '-la'; // Long format with all files (including hidden)
505
+ if (!options?.includeHidden) {
506
+ lsFlags = '-l'; // Long format without hidden files
507
+ }
508
+ if (options?.recursive) {
509
+ lsFlags += 'R'; // Recursive
510
+ }
511
+
512
+ const command = `ls ${lsFlags} "${path}" 2>/dev/null || echo "DIRECTORY_NOT_FOUND"`;
513
+ const result = await this.exec(command);
514
+
515
+ if (result.stdout.includes('DIRECTORY_NOT_FOUND')) {
516
+ return {
517
+ success: false,
518
+ exitCode: 1,
519
+ files: [],
520
+ path
521
+ };
522
+ }
523
+
524
+ // Parse ls output into structured file data
525
+ const files = this.parseLsOutput(result.stdout, path);
526
+
527
+ return {
528
+ success: result.exitCode === 0,
529
+ exitCode: result.exitCode,
530
+ files,
531
+ path
532
+ };
533
+ }
534
+
535
+ private parseLsOutput(lsOutput: string, basePath: string): any[] {
536
+ const lines = lsOutput.split('\n').filter(line => line.trim());
537
+ const files: any[] = [];
538
+
539
+ for (const line of lines) {
540
+ // Skip total line and empty lines
541
+ if (line.startsWith('total') || !line.trim()) continue;
542
+
543
+ // Parse ls -l format: permissions, links, user, group, size, date, name
544
+ const match = line.match(/^([-dlrwx]+)\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
545
+ if (!match) continue;
546
+
547
+ const [, permissions, size, dateStr, name] = match;
548
+
549
+ // Determine file type from first character of permissions
550
+ let type: 'file' | 'directory' | 'symlink' | 'other' = 'file';
551
+ if (permissions.startsWith('d')) type = 'directory';
552
+ else if (permissions.startsWith('l')) type = 'symlink';
553
+ else if (!permissions.startsWith('-')) type = 'other';
554
+
555
+ // Extract permission booleans for user (first 3 chars after type indicator)
556
+ const userPerms = permissions.substring(1, 4);
557
+
558
+ files.push({
559
+ name,
560
+ absolutePath: `${basePath}/${name}`,
561
+ relativePath: name,
562
+ type,
563
+ size: parseInt(size),
564
+ modifiedAt: dateStr, // Simplified date parsing
565
+ mode: permissions,
566
+ permissions: {
567
+ readable: userPerms[0] === 'r',
568
+ writable: userPerms[1] === 'w',
569
+ executable: userPerms[2] === 'x'
570
+ }
571
+ });
572
+ }
573
+
574
+ return files;
575
+ }
576
+
577
+ // Process Management Methods
578
+
579
+ async startProcess(command: string, options?: {
580
+ processId?: string;
581
+ timeout?: number;
582
+ env?: Record<string, string>;
583
+ cwd?: string;
584
+ encoding?: string;
585
+ autoCleanup?: boolean;
586
+ }): Promise<ProcessRecord> {
587
+ // Validate cwd if provided - must be absolute path
588
+ if (options?.cwd) {
589
+ if (!options.cwd.startsWith('/')) {
590
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
591
+ }
592
+ }
593
+
594
+ const processId = options?.processId || `proc_${Date.now()}_${randomBytes(6).toString('hex')}`;
595
+
596
+ // Check if process ID already exists in this session
597
+ if (this.processes.has(processId)) {
598
+ throw new Error(`Process already exists in session: ${processId}`);
599
+ }
600
+
601
+ console.log(`[Session ${this.options.id}] Starting process: ${command} (ID: ${processId})`);
602
+
603
+ // Create separate temp files for stdout and stderr
604
+ const stdoutFile = `/tmp/proc_${processId}.stdout`;
605
+ const stderrFile = `/tmp/proc_${processId}.stderr`;
606
+
607
+ // Create process record
608
+ const processRecord: ProcessRecord = {
609
+ id: processId,
610
+ command,
611
+ status: 'starting',
612
+ startTime: new Date(),
613
+ stdout: '',
614
+ stderr: '',
615
+ outputListeners: new Set(),
616
+ statusListeners: new Set()
617
+ };
618
+
619
+ this.processes.set(processId, processRecord);
620
+
621
+ // Start the process in background with nohup
622
+ // Keep stdout and stderr separate
623
+ const backgroundCommand = `nohup ${command} > ${stdoutFile} 2> ${stderrFile} & echo $!`;
624
+
625
+ try {
626
+ // Execute the background command and get PID directly
627
+ const result = await this.exec(backgroundCommand, { cwd: options?.cwd });
628
+ const pid = parseInt(result.stdout.trim(), 10);
629
+
630
+ if (Number.isNaN(pid)) {
631
+ throw new Error(`Failed to get process PID from output: ${result.stdout}`);
632
+ }
633
+
634
+ processRecord.pid = pid;
635
+ processRecord.status = 'running';
636
+
637
+ // Store the output file paths for later retrieval
638
+ processRecord.stdoutFile = stdoutFile;
639
+ processRecord.stderrFile = stderrFile;
640
+
641
+ console.log(`[Session ${this.options.id}] Process ${processId} started with PID ${pid}`);
642
+
643
+ } catch (error) {
644
+ processRecord.status = 'error';
645
+ processRecord.endTime = new Date();
646
+ processRecord.stderr = `Failed to start process: ${error instanceof Error ? error.message : String(error)}`;
647
+
648
+ // Notify status listeners
649
+ for (const listener of processRecord.statusListeners) {
650
+ listener('error');
651
+ }
652
+
653
+ // Clean up temp files
654
+ this.exec(`rm -f ${stdoutFile} ${stderrFile}`).catch(() => {});
655
+
656
+ throw error;
657
+ }
658
+
659
+ return processRecord;
660
+ }
661
+
662
+ // Check and update process status on-demand
663
+ private async updateProcessStatus(processRecord: ProcessRecord): Promise<void> {
664
+ if (!processRecord.pid || processRecord.status !== 'running') {
665
+ return; // Nothing to check
666
+ }
667
+
668
+ try {
669
+ // Check if process is still running (kill -0 just checks, doesn't actually kill)
670
+ const checkResult = await this.exec(`kill -0 ${processRecord.pid} 2>/dev/null && echo "running" || echo "stopped"`);
671
+ const isRunning = checkResult.stdout.trim() === 'running';
672
+
673
+ if (!isRunning) {
674
+ // Process has stopped
675
+ processRecord.status = 'completed';
676
+ processRecord.endTime = new Date();
677
+
678
+ // Try to get exit status from wait (may not work if process already reaped)
679
+ try {
680
+ const waitResult = await this.exec(`wait ${processRecord.pid} 2>/dev/null; echo $?`);
681
+ const exitCode = parseInt(waitResult.stdout.trim(), 10);
682
+ if (!Number.isNaN(exitCode)) {
683
+ processRecord.exitCode = exitCode;
684
+ if (exitCode !== 0) {
685
+ processRecord.status = 'failed';
686
+ }
687
+ }
688
+ } catch {
689
+ // Can't get exit code, that's okay
690
+ }
691
+
692
+ // Read final output if not already cached
693
+ if ((processRecord.stdoutFile || processRecord.stderrFile) && (!processRecord.stdout && !processRecord.stderr)) {
694
+ await this.updateProcessLogs(processRecord);
695
+ }
696
+
697
+ // Notify status listeners
698
+ for (const listener of processRecord.statusListeners) {
699
+ listener(processRecord.status);
700
+ }
701
+
702
+ console.log(`[Session ${this.options.id}] Process ${processRecord.id} completed with status: ${processRecord.status}`);
703
+ }
704
+ } catch (error) {
705
+ console.error(`[Session ${this.options.id}] Error checking process ${processRecord.id} status:`, error);
706
+ }
707
+ }
708
+
709
+ // Update process logs from output files
710
+ private async updateProcessLogs(processRecord: ProcessRecord): Promise<void> {
711
+ if (!processRecord.stdoutFile && !processRecord.stderrFile) {
712
+ return;
713
+ }
714
+
715
+ try {
716
+ // Read stdout
717
+ if (processRecord.stdoutFile) {
718
+ const result = await this.exec(`cat ${processRecord.stdoutFile} 2>/dev/null || true`);
719
+ const newStdout = result.stdout;
720
+
721
+ // Check if there's new stdout
722
+ if (newStdout.length > processRecord.stdout.length) {
723
+ const delta = newStdout.substring(processRecord.stdout.length);
724
+ processRecord.stdout = newStdout;
725
+
726
+ // Notify output listeners if any
727
+ for (const listener of processRecord.outputListeners) {
728
+ listener('stdout', delta);
729
+ }
730
+ }
731
+ }
732
+
733
+ // Read stderr
734
+ if (processRecord.stderrFile) {
735
+ const result = await this.exec(`cat ${processRecord.stderrFile} 2>/dev/null || true`);
736
+ const newStderr = result.stdout; // Note: reading stderr file content from result.stdout
737
+
738
+ // Check if there's new stderr
739
+ if (newStderr.length > processRecord.stderr.length) {
740
+ const delta = newStderr.substring(processRecord.stderr.length);
741
+ processRecord.stderr = newStderr;
742
+
743
+ // Notify output listeners if any
744
+ for (const listener of processRecord.outputListeners) {
745
+ listener('stderr', delta);
746
+ }
747
+ }
748
+ }
749
+ } catch (error) {
750
+ console.error(`[Session ${this.options.id}] Error reading process ${processRecord.id} logs:`, error);
751
+ }
752
+ }
753
+
754
+ async getProcess(processId: string): Promise<ProcessRecord | undefined> {
755
+ const process = this.processes.get(processId);
756
+ if (process) {
757
+ // Update status before returning
758
+ await this.updateProcessStatus(process);
759
+ }
760
+ return process;
761
+ }
762
+
763
+ async listProcesses(): Promise<ProcessRecord[]> {
764
+ const processes = Array.from(this.processes.values());
765
+
766
+ // Update status for all running processes
767
+ for (const process of processes) {
768
+ if (process.status === 'running') {
769
+ await this.updateProcessStatus(process);
770
+ }
771
+ }
772
+
773
+ return processes;
774
+ }
775
+
776
+ async killProcess(processId: string): Promise<boolean> {
777
+ const process = this.processes.get(processId);
778
+ if (!process) {
779
+ return false;
780
+ }
781
+
782
+ // Stop monitoring first
783
+ this.stopProcessMonitoring(process);
784
+
785
+ // Only try to kill if process is running
786
+ if (process.pid && (process.status === 'running' || process.status === 'starting')) {
787
+ try {
788
+ // Send SIGTERM to the process
789
+ await this.exec(`kill ${process.pid} 2>/dev/null || true`);
790
+ console.log(`[Session ${this.options.id}] Sent SIGTERM to process ${processId} (PID: ${process.pid})`);
791
+
792
+ // Give it a moment to terminate gracefully (500ms), then force kill if needed
793
+ await new Promise(resolve => setTimeout(resolve, 500));
794
+
795
+ // Check if still running and force kill if needed
796
+ const checkResult = await this.exec(`kill -0 ${process.pid} 2>/dev/null && echo "running" || echo "stopped"`);
797
+ if (checkResult.stdout.trim() === 'running') {
798
+ await this.exec(`kill -9 ${process.pid} 2>/dev/null || true`);
799
+ console.log(`[Session ${this.options.id}] Force killed process ${processId} (PID: ${process.pid})`);
800
+ }
801
+ } catch (error) {
802
+ console.error(`[Session ${this.options.id}] Error killing process ${processId}:`, error);
803
+ }
804
+
805
+ process.status = 'killed';
806
+ process.endTime = new Date();
807
+
808
+ // Read final output before cleanup
809
+ if (process.stdoutFile || process.stderrFile) {
810
+ await this.updateProcessLogs(process);
811
+ }
812
+
813
+ // Notify status listeners
814
+ for (const listener of process.statusListeners) {
815
+ listener('killed');
816
+ }
817
+
818
+ // Clean up temp files
819
+ if (process.stdoutFile || process.stderrFile) {
820
+ this.exec(`rm -f ${process.stdoutFile} ${process.stderrFile}`).catch(() => {});
821
+ }
822
+
823
+ return true;
824
+ }
825
+
826
+ // Process not running, just update status
827
+ if (process.status === 'running' || process.status === 'starting') {
828
+ process.status = 'killed';
829
+ process.endTime = new Date();
830
+
831
+ // Notify status listeners
832
+ for (const listener of process.statusListeners) {
833
+ listener('killed');
834
+ }
835
+ }
836
+
837
+ return true;
838
+ }
839
+
840
+ async killAllProcesses(): Promise<number> {
841
+ let killedCount = 0;
842
+ for (const [id, _] of this.processes) {
843
+ if (await this.killProcess(id)) {
844
+ killedCount++;
845
+ }
846
+ }
847
+ return killedCount;
848
+ }
849
+
850
+ // Get process logs (updates from files if needed)
851
+ async getProcessLogs(processId: string): Promise<{ stdout: string; stderr: string }> {
852
+ const process = this.processes.get(processId);
853
+ if (!process) {
854
+ throw new Error(`Process not found: ${processId}`);
855
+ }
856
+
857
+ // Update logs from files
858
+ if (process.stdoutFile || process.stderrFile) {
859
+ await this.updateProcessLogs(process);
860
+ }
861
+
862
+ // Return current logs
863
+ return {
864
+ stdout: process.stdout,
865
+ stderr: process.stderr
866
+ };
867
+ }
868
+
869
+ // Start monitoring a process for output changes
870
+ startProcessMonitoring(processRecord: ProcessRecord): void {
871
+ // Don't monitor if already monitoring or process not running
872
+ if (processRecord.monitoringInterval || processRecord.status !== 'running') {
873
+ return;
874
+ }
875
+
876
+ console.log(`[Session ${this.options.id}] Starting monitoring for process ${processRecord.id}`);
877
+
878
+ // Poll every 100ms for near real-time updates
879
+ processRecord.monitoringInterval = setInterval(async () => {
880
+ // Stop monitoring if no listeners or process not running
881
+ if (processRecord.outputListeners.size === 0 || processRecord.status !== 'running') {
882
+ this.stopProcessMonitoring(processRecord);
883
+ return;
884
+ }
885
+
886
+ // Update logs from temp files (this will notify listeners if there's new content)
887
+ await this.updateProcessLogs(processRecord);
888
+
889
+ // Also check if process is still running
890
+ await this.updateProcessStatus(processRecord);
891
+ }, 100);
892
+ }
893
+
894
+ // Stop monitoring a process
895
+ stopProcessMonitoring(processRecord: ProcessRecord): void {
896
+ if (processRecord.monitoringInterval) {
897
+ console.log(`[Session ${this.options.id}] Stopping monitoring for process ${processRecord.id}`);
898
+ clearInterval(processRecord.monitoringInterval);
899
+ processRecord.monitoringInterval = undefined;
900
+ }
901
+ }
902
+
903
+ async destroy(): Promise<void> {
904
+ // Stop all monitoring intervals first
905
+ for (const process of this.processes.values()) {
906
+ this.stopProcessMonitoring(process);
907
+ }
908
+
909
+ // Kill all processes
910
+ await this.killAllProcesses();
911
+
912
+ // Clean up all temp files
913
+ for (const [id, process] of this.processes) {
914
+ if (process.stdoutFile || process.stderrFile) {
915
+ await this.exec(`rm -f ${process.stdoutFile} ${process.stderrFile}`).catch(() => {});
916
+ }
917
+ }
918
+
919
+ if (this.control) {
920
+ // Send exit command
921
+ const msg: ControlMessage = { type: 'exit', id: 'destroy' };
922
+ this.control.stdin?.write(`${JSON.stringify(msg)}\n`);
923
+
924
+ // Give it a moment to exit cleanly
925
+ setTimeout(() => {
926
+ if (this.control && !this.control.killed) {
927
+ this.control.kill('SIGTERM');
928
+ }
929
+ }, 100);
930
+
931
+ this.cleanup();
932
+ }
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Manages isolated sessions for command execution.
938
+ * Each session maintains its own state (pwd, env vars, processes).
939
+ */
940
+ export class SessionManager {
941
+ private sessions = new Map<string, Session>();
942
+
943
+ async createSession(options: SessionOptions): Promise<Session> {
944
+ // Validate cwd if provided - must be absolute path
945
+ if (options.cwd) {
946
+ if (!options.cwd.startsWith('/')) {
947
+ throw new Error(`cwd must be an absolute path starting with '/', got: ${options.cwd}`);
948
+ }
949
+ }
950
+
951
+ // Clean up existing session with same name
952
+ const existing = this.sessions.get(options.id);
953
+ if (existing) {
954
+ existing.destroy();
955
+ }
956
+
957
+ // Create new session
958
+ const session = new Session(options);
959
+ await session.initialize();
960
+
961
+ this.sessions.set(options.id, session);
962
+ console.log(`[SessionManager] Created session '${options.id}'`);
963
+ return session;
964
+ }
965
+
966
+ getSession(id: string): Session | undefined {
967
+ return this.sessions.get(id);
968
+ }
969
+
970
+ listSessions(): string[] {
971
+ return Array.from(this.sessions.keys());
972
+ }
973
+
974
+ // Helper to get or create default session - reduces duplication
975
+ 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;
985
+ }
986
+
987
+ async exec(command: string, options?: { cwd?: string }): Promise<ExecResult> {
988
+ const defaultSession = await this.getOrCreateDefaultSession();
989
+ return defaultSession.exec(command, options);
990
+ }
991
+
992
+ async *execStream(command: string, options?: { cwd?: string }): AsyncGenerator<ExecEvent> {
993
+ const defaultSession = await this.getOrCreateDefaultSession();
994
+ yield* defaultSession.execStream(command, options);
995
+ }
996
+
997
+ // File Operations - Clean method names following existing pattern
998
+ async writeFile(path: string, content: string, encoding?: string): Promise<{ success: boolean; exitCode: number; path: string }> {
999
+ const defaultSession = await this.getOrCreateDefaultSession();
1000
+ return defaultSession.writeFileOperation(path, content, encoding);
1001
+ }
1002
+
1003
+ async readFile(path: string, encoding?: string): Promise<{ success: boolean; exitCode: number; content: string; path: string }> {
1004
+ const defaultSession = await this.getOrCreateDefaultSession();
1005
+ return defaultSession.readFileOperation(path, encoding);
1006
+ }
1007
+
1008
+ async mkdir(path: string, recursive?: boolean): Promise<{ success: boolean; exitCode: number; path: string; recursive: boolean }> {
1009
+ const defaultSession = await this.getOrCreateDefaultSession();
1010
+ return defaultSession.mkdirOperation(path, recursive);
1011
+ }
1012
+
1013
+ async deleteFile(path: string): Promise<{ success: boolean; exitCode: number; path: string }> {
1014
+ const defaultSession = await this.getOrCreateDefaultSession();
1015
+ return defaultSession.deleteFileOperation(path);
1016
+ }
1017
+
1018
+ async renameFile(oldPath: string, newPath: string): Promise<{ success: boolean; exitCode: number; oldPath: string; newPath: string }> {
1019
+ const defaultSession = await this.getOrCreateDefaultSession();
1020
+ return defaultSession.renameFileOperation(oldPath, newPath);
1021
+ }
1022
+
1023
+ async moveFile(sourcePath: string, destinationPath: string): Promise<{ success: boolean; exitCode: number; sourcePath: string; destinationPath: string }> {
1024
+ const defaultSession = await this.getOrCreateDefaultSession();
1025
+ return defaultSession.moveFileOperation(sourcePath, destinationPath);
1026
+ }
1027
+
1028
+ async listFiles(path: string, options?: { recursive?: boolean; includeHidden?: boolean }): Promise<{ success: boolean; exitCode: number; files: any[]; path: string }> {
1029
+ const defaultSession = await this.getOrCreateDefaultSession();
1030
+ return defaultSession.listFilesOperation(path, options);
1031
+ }
1032
+
1033
+ async destroyAll(): Promise<void> {
1034
+ for (const session of this.sessions.values()) {
1035
+ await session.destroy();
1036
+ }
1037
+ this.sessions.clear();
1038
+ }
1039
+ }