@cloudflare/sandbox 0.3.6 → 0.4.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 (120) hide show
  1. package/.turbo/turbo-build.log +44 -0
  2. package/CHANGELOG.md +6 -8
  3. package/Dockerfile +88 -18
  4. package/README.md +89 -824
  5. package/dist/{chunk-JTKON2SH.js → chunk-BCJ7SF3Q.js} +9 -5
  6. package/dist/chunk-BCJ7SF3Q.js.map +1 -0
  7. package/dist/chunk-BFVUNTP4.js +104 -0
  8. package/dist/chunk-BFVUNTP4.js.map +1 -0
  9. package/dist/{chunk-NNGBXDMY.js → chunk-EKSWCBCA.js} +3 -6
  10. package/dist/chunk-EKSWCBCA.js.map +1 -0
  11. package/dist/chunk-HGF554LH.js +2236 -0
  12. package/dist/chunk-HGF554LH.js.map +1 -0
  13. package/dist/{chunk-6UAWTJ5S.js → chunk-Z532A7QC.js} +13 -20
  14. package/dist/{chunk-6UAWTJ5S.js.map → chunk-Z532A7QC.js.map} +1 -1
  15. package/dist/file-stream.d.ts +16 -38
  16. package/dist/file-stream.js +1 -2
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.js +35 -39
  19. package/dist/index.js.map +1 -1
  20. package/dist/interpreter.d.ts +3 -3
  21. package/dist/interpreter.js +2 -2
  22. package/dist/request-handler.d.ts +4 -3
  23. package/dist/request-handler.js +4 -7
  24. package/dist/sandbox-D9K2ypln.d.ts +583 -0
  25. package/dist/sandbox.d.ts +3 -3
  26. package/dist/sandbox.js +4 -7
  27. package/dist/security.d.ts +4 -3
  28. package/dist/security.js +3 -3
  29. package/dist/sse-parser.js +1 -1
  30. package/package.json +11 -5
  31. package/src/clients/base-client.ts +280 -0
  32. package/src/clients/command-client.ts +115 -0
  33. package/src/clients/file-client.ts +269 -0
  34. package/src/clients/git-client.ts +92 -0
  35. package/src/clients/index.ts +63 -0
  36. package/src/{interpreter-client.ts → clients/interpreter-client.ts} +148 -171
  37. package/src/clients/port-client.ts +105 -0
  38. package/src/clients/process-client.ts +177 -0
  39. package/src/clients/sandbox-client.ts +41 -0
  40. package/src/clients/types.ts +84 -0
  41. package/src/clients/utility-client.ts +94 -0
  42. package/src/errors/adapter.ts +180 -0
  43. package/src/errors/classes.ts +469 -0
  44. package/src/errors/index.ts +105 -0
  45. package/src/file-stream.ts +119 -117
  46. package/src/index.ts +81 -69
  47. package/src/interpreter.ts +17 -8
  48. package/src/request-handler.ts +69 -43
  49. package/src/sandbox.ts +694 -533
  50. package/src/security.ts +14 -23
  51. package/src/sse-parser.ts +4 -8
  52. package/startup.sh +3 -0
  53. package/tests/base-client.test.ts +328 -0
  54. package/tests/command-client.test.ts +407 -0
  55. package/tests/file-client.test.ts +643 -0
  56. package/tests/file-stream.test.ts +306 -0
  57. package/tests/git-client.test.ts +328 -0
  58. package/tests/port-client.test.ts +301 -0
  59. package/tests/process-client.test.ts +658 -0
  60. package/tests/sandbox.test.ts +465 -0
  61. package/tests/sse-parser.test.ts +290 -0
  62. package/tests/utility-client.test.ts +266 -0
  63. package/tests/wrangler.jsonc +35 -0
  64. package/tsconfig.json +9 -1
  65. package/vitest.config.ts +31 -0
  66. package/container_src/bun.lock +0 -76
  67. package/container_src/circuit-breaker.ts +0 -121
  68. package/container_src/control-process.ts +0 -784
  69. package/container_src/handler/exec.ts +0 -185
  70. package/container_src/handler/file.ts +0 -457
  71. package/container_src/handler/git.ts +0 -130
  72. package/container_src/handler/ports.ts +0 -314
  73. package/container_src/handler/process.ts +0 -568
  74. package/container_src/handler/session.ts +0 -92
  75. package/container_src/index.ts +0 -601
  76. package/container_src/interpreter-service.ts +0 -276
  77. package/container_src/isolation.ts +0 -1213
  78. package/container_src/mime-processor.ts +0 -255
  79. package/container_src/package.json +0 -18
  80. package/container_src/runtime/executors/javascript/node_executor.ts +0 -123
  81. package/container_src/runtime/executors/python/ipython_executor.py +0 -338
  82. package/container_src/runtime/executors/typescript/ts_executor.ts +0 -138
  83. package/container_src/runtime/process-pool.ts +0 -464
  84. package/container_src/shell-escape.ts +0 -42
  85. package/container_src/startup.sh +0 -11
  86. package/container_src/types.ts +0 -131
  87. package/dist/chunk-32UDXUPC.js +0 -671
  88. package/dist/chunk-32UDXUPC.js.map +0 -1
  89. package/dist/chunk-5DILEXGY.js +0 -85
  90. package/dist/chunk-5DILEXGY.js.map +0 -1
  91. package/dist/chunk-D3U63BZP.js +0 -240
  92. package/dist/chunk-D3U63BZP.js.map +0 -1
  93. package/dist/chunk-FXYPFGOZ.js +0 -129
  94. package/dist/chunk-FXYPFGOZ.js.map +0 -1
  95. package/dist/chunk-JTKON2SH.js.map +0 -1
  96. package/dist/chunk-NNGBXDMY.js.map +0 -1
  97. package/dist/chunk-SQLJNZ3K.js +0 -674
  98. package/dist/chunk-SQLJNZ3K.js.map +0 -1
  99. package/dist/chunk-W7TVRPBG.js +0 -108
  100. package/dist/chunk-W7TVRPBG.js.map +0 -1
  101. package/dist/client-B3RUab0s.d.ts +0 -225
  102. package/dist/client.d.ts +0 -4
  103. package/dist/client.js +0 -7
  104. package/dist/client.js.map +0 -1
  105. package/dist/errors.d.ts +0 -95
  106. package/dist/errors.js +0 -27
  107. package/dist/errors.js.map +0 -1
  108. package/dist/interpreter-client.d.ts +0 -4
  109. package/dist/interpreter-client.js +0 -9
  110. package/dist/interpreter-client.js.map +0 -1
  111. package/dist/interpreter-types.d.ts +0 -259
  112. package/dist/interpreter-types.js +0 -9
  113. package/dist/interpreter-types.js.map +0 -1
  114. package/dist/types.d.ts +0 -453
  115. package/dist/types.js +0 -45
  116. package/dist/types.js.map +0 -1
  117. package/src/client.ts +0 -1048
  118. package/src/errors.ts +0 -219
  119. package/src/interpreter-types.ts +0 -390
  120. package/src/types.ts +0 -571
@@ -1,784 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Control Process for Isolated Command Execution
5
- *
6
- * This process manages a persistent bash shell with optional namespace isolation.
7
- * It maintains session state (pwd, env vars) across commands while providing
8
- * security isolation when requested.
9
- *
10
- * Architecture:
11
- * - Receives commands via stdin as JSON messages
12
- * - Executes them in a persistent bash shell
13
- * - Optionally uses Linux 'unshare' for PID namespace isolation
14
- * - Returns results via stdout as JSON messages
15
- * - Uses file-based IPC for reliable handling of any output type
16
- *
17
- * Isolation (when enabled):
18
- * - Uses 'unshare --pid --fork --mount-proc' from util-linux
19
- * - Creates PID namespace: sandboxed code cannot see host processes
20
- * - Mounts isolated /proc: hides platform secrets and control plane
21
- * - Requires CAP_SYS_ADMIN capability (available in production)
22
- * - Falls back gracefully to non-isolated mode if unavailable
23
- */
24
-
25
- import { type ChildProcess, spawn } from 'node:child_process';
26
- import * as crypto from 'node:crypto';
27
- import * as fs from 'node:fs';
28
- import * as os from 'node:os';
29
- import * as path from 'node:path';
30
- import { escapeShellArg, escapeShellPath } from './shell-escape';
31
-
32
- // Parse environment configuration
33
- const sessionId = process.env.SESSION_ID || 'default';
34
- const sessionCwd = process.env.SESSION_CWD || '/workspace';
35
- let isIsolated = process.env.SESSION_ISOLATED === '1';
36
-
37
- // Configuration constants (can be overridden via env vars)
38
- const COMMAND_TIMEOUT_MS = parseInt(process.env.COMMAND_TIMEOUT_MS || '30000');
39
- const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '30000');
40
- const TEMP_FILE_MAX_AGE_MS = parseInt(process.env.TEMP_FILE_MAX_AGE_MS || '60000');
41
-
42
- // Secure temp directory setup
43
- const BASE_TEMP_DIR = process.env.TEMP_DIR || '/tmp';
44
- let SECURE_TEMP_DIR: string;
45
- const TEMP_DIR_PERMISSIONS = 0o700; // rwx------ (only owner can access)
46
- const TEMP_FILE_PERMISSIONS = 0o600; // rw------- (only owner can read/write)
47
-
48
- // Message types for communication with parent process
49
- interface ControlMessage {
50
- type: 'exec' | 'exec_stream' | 'exit';
51
- id: string;
52
- command?: string;
53
- cwd?: string;
54
- }
55
-
56
- interface ControlResponse {
57
- type: 'result' | 'error' | 'ready' | 'stream_event';
58
- id: string;
59
- stdout?: string;
60
- stderr?: string;
61
- exitCode?: number;
62
- error?: string;
63
- event?: StreamEvent;
64
- }
65
-
66
- interface StreamEvent {
67
- type: 'start' | 'stdout' | 'stderr' | 'complete' | 'error';
68
- timestamp: string;
69
- command?: string;
70
- data?: string;
71
- exitCode?: number;
72
- error?: string;
73
- result?: {
74
- stdout: string;
75
- stderr: string;
76
- exitCode: number;
77
- success: boolean;
78
- };
79
- }
80
-
81
- // Track active command files for cleanup
82
- const activeFiles = new Set<string>();
83
-
84
- // Track processing state for each command to prevent race conditions
85
- const processingState = new Map<string, boolean>();
86
-
87
- // The shell process we're managing
88
- let shell: ChildProcess;
89
- let shellAlive = true;
90
-
91
- /**
92
- * Initialize secure temp directory with proper permissions
93
- * Creates a process-specific directory to prevent race conditions and unauthorized access
94
- */
95
- function initializeSecureTempDir(): void {
96
- // Create a unique directory name using process ID and random bytes
97
- const processId = process.pid;
98
- const randomBytes = crypto.randomBytes(8).toString('hex');
99
- const dirName = `sandbox_${sessionId}_${processId}_${randomBytes}`;
100
-
101
- SECURE_TEMP_DIR = path.join(BASE_TEMP_DIR, dirName);
102
-
103
- try {
104
- // Create the directory with restrictive permissions (700 - only owner can access)
105
- fs.mkdirSync(SECURE_TEMP_DIR, { mode: TEMP_DIR_PERMISSIONS });
106
- logError(`Created secure temp directory: ${SECURE_TEMP_DIR}`);
107
-
108
- // Register cleanup on process exit
109
- const cleanup = () => {
110
- try {
111
- // Remove all files in the directory
112
- const files = fs.readdirSync(SECURE_TEMP_DIR);
113
- files.forEach(file => {
114
- try {
115
- fs.unlinkSync(path.join(SECURE_TEMP_DIR, file));
116
- } catch (e) {
117
- // Ignore individual file errors during cleanup
118
- }
119
- });
120
- // Remove the directory itself
121
- fs.rmdirSync(SECURE_TEMP_DIR);
122
- logError(`Cleaned up secure temp directory: ${SECURE_TEMP_DIR}`);
123
- } catch (e) {
124
- logError(`Failed to cleanup temp directory: ${e}`);
125
- }
126
- };
127
-
128
- // Register cleanup handlers
129
- process.on('exit', cleanup);
130
- process.on('SIGTERM', cleanup);
131
- process.on('SIGINT', cleanup);
132
- } catch (error) {
133
- logError(`Failed to create secure temp directory: ${error}`);
134
- // Fall back to using the base temp dir if we can't create a secure one
135
- SECURE_TEMP_DIR = BASE_TEMP_DIR;
136
- }
137
- }
138
-
139
- /**
140
- * Create a secure temp file with proper permissions
141
- * @param prefix - File prefix (cmd, out, err, exit)
142
- * @param id - Command ID
143
- * @returns Full path to the created file
144
- */
145
- function createSecureTempFile(prefix: string, id: string): string {
146
- // Use crypto.randomBytes for additional entropy in filename
147
- const randomSuffix = crypto.randomBytes(4).toString('hex');
148
- const filename = `${prefix}_${id}_${randomSuffix}`;
149
- const filepath = path.join(SECURE_TEMP_DIR, filename);
150
-
151
- // Create empty file with restrictive permissions (600 - only owner can read/write)
152
- const fd = fs.openSync(filepath, 'w', TEMP_FILE_PERMISSIONS);
153
- fs.closeSync(fd);
154
-
155
- return filepath;
156
- }
157
-
158
- /**
159
- * Atomically cleanup temp files for a command
160
- * Prevents race conditions by using atomic operations
161
- * @param files - Array of file paths to clean up
162
- * @param commandId - Command ID for logging
163
- */
164
- function atomicCleanupFiles(files: string[], commandId: string): void {
165
- files.forEach(file => {
166
- try {
167
- // First, remove from active files set to prevent re-use
168
- activeFiles.delete(file);
169
-
170
- // Attempt to rename file before deletion (atomic operation)
171
- const deletionMarker = `${file}.deleting`;
172
- try {
173
- fs.renameSync(file, deletionMarker);
174
- fs.unlinkSync(deletionMarker);
175
- } catch (renameError) {
176
- // If rename fails, try direct deletion
177
- fs.unlinkSync(file);
178
- }
179
- } catch (e) {
180
- // File might already be deleted, which is fine
181
- const error = e as NodeJS.ErrnoException;
182
- if (error.code !== 'ENOENT') {
183
- logError(`Failed to cleanup file ${file} for command ${commandId}: ${error.message}`);
184
- }
185
- }
186
- });
187
-
188
- // Clean up processing state after files are removed
189
- processingState.delete(commandId);
190
- }
191
-
192
- /**
193
- * Send a response to the parent process
194
- */
195
- function sendResponse(response: ControlResponse): void {
196
- process.stdout.write(`${JSON.stringify(response)}\n`);
197
- }
198
-
199
- /**
200
- * Log to stderr for debugging (visible in parent process logs)
201
- */
202
- function logError(message: string, error?: unknown): void {
203
- console.error(`[Control] ${message}`, error || '');
204
- }
205
-
206
- /**
207
- * Clean up orphaned temp files periodically
208
- */
209
- function cleanupTempFiles(): void {
210
- try {
211
- // Only clean up files in our secure directory
212
- if (!SECURE_TEMP_DIR || SECURE_TEMP_DIR === BASE_TEMP_DIR) {
213
- return; // Skip cleanup if we're using the fallback shared directory
214
- }
215
-
216
- const files = fs.readdirSync(SECURE_TEMP_DIR);
217
- const now = Date.now();
218
-
219
- files.forEach(file => {
220
- // Match our temp file pattern and check age
221
- if (file.match(/^(cmd|out|err|exit)_[a-f0-9-]+_[a-f0-9]+/)) {
222
- const filePath = path.join(SECURE_TEMP_DIR, file);
223
- try {
224
- const stats = fs.statSync(filePath);
225
- // Remove files older than max age that aren't active
226
- if (now - stats.mtimeMs > TEMP_FILE_MAX_AGE_MS && !activeFiles.has(filePath)) {
227
- fs.unlinkSync(filePath);
228
- logError(`Cleaned up orphaned temp file: ${file}`);
229
- }
230
- } catch (e) {
231
- // File might have been removed already
232
- }
233
- }
234
- });
235
- } catch (e) {
236
- logError('Cleanup error:', e);
237
- }
238
- }
239
-
240
- /**
241
- * Build the bash script for executing a command with optional cwd override
242
- */
243
- function buildExecScript(
244
- cmdFile: string,
245
- outFile: string,
246
- errFile: string,
247
- exitFile: string,
248
- msgId: string,
249
- msgCwd?: string,
250
- completionMarker: string = 'DONE'
251
- ): string {
252
- // Escape all file paths and identifiers to prevent injection
253
- const safeCmdFile = escapeShellPath(cmdFile);
254
- const safeOutFile = escapeShellPath(outFile);
255
- const safeErrFile = escapeShellPath(errFile);
256
- const safeExitFile = escapeShellPath(exitFile);
257
- const safeCompletionMarker = escapeShellArg(completionMarker);
258
- const safeMsgId = escapeShellArg(msgId);
259
-
260
- if (msgCwd) {
261
- // Escape the directory path (validation happens at SDK layer)
262
- const safeMsgCwd = escapeShellPath(msgCwd);
263
-
264
- // If cwd is provided, change directory for this command only
265
- return `
266
- # Execute command with temporary cwd override
267
- PREV_DIR=$(pwd)
268
- cd ${safeMsgCwd} || { echo "Failed to change directory to ${safeMsgCwd}" > ${safeErrFile}; echo 1 > ${safeExitFile}; echo "${safeCompletionMarker}:${safeMsgId}"; return; }
269
- source ${safeCmdFile} > ${safeOutFile} 2> ${safeErrFile}
270
- echo $? > ${safeExitFile}
271
- cd "$PREV_DIR"
272
- echo "${safeCompletionMarker}:${safeMsgId}"
273
- `;
274
- } else {
275
- // Default behavior - execute in current directory (preserves session state)
276
- return `
277
- # Execute command in current shell - maintains working directory changes
278
- source ${safeCmdFile} > ${safeOutFile} 2> ${safeErrFile}
279
- echo $? > ${safeExitFile}
280
- echo "${safeCompletionMarker}:${safeMsgId}"
281
- `;
282
- }
283
- }
284
-
285
- /**
286
- * Handle an exec command (non-streaming)
287
- */
288
- async function handleExecCommand(msg: ControlMessage): Promise<void> {
289
- if (!shellAlive) {
290
- sendResponse({
291
- type: 'error',
292
- id: msg.id,
293
- error: 'Shell is not alive'
294
- });
295
- return;
296
- }
297
-
298
- if (!msg.command) {
299
- sendResponse({
300
- type: 'error',
301
- id: msg.id,
302
- error: 'No command provided'
303
- });
304
- return;
305
- }
306
-
307
- // Create secure temp files for this command
308
- const cmdFile = createSecureTempFile('cmd', msg.id);
309
- const outFile = createSecureTempFile('out', msg.id);
310
- const errFile = createSecureTempFile('err', msg.id);
311
- const exitFile = createSecureTempFile('exit', msg.id);
312
-
313
- // Track these files as active
314
- activeFiles.add(cmdFile);
315
- activeFiles.add(outFile);
316
- activeFiles.add(errFile);
317
- activeFiles.add(exitFile);
318
-
319
- // Initialize processing state to prevent race conditions
320
- processingState.set(msg.id, false);
321
-
322
- // Write command to file securely (file already has proper permissions)
323
- fs.writeFileSync(cmdFile, msg.command, { encoding: 'utf8', mode: TEMP_FILE_PERMISSIONS });
324
-
325
- // Build and execute the script
326
- const execScript = buildExecScript(cmdFile, outFile, errFile, exitFile, msg.id, msg.cwd);
327
-
328
- // Set up completion handler
329
- const onData = (chunk: Buffer) => {
330
- const output = chunk.toString();
331
- if (output.includes(`DONE:${msg.id}`)) {
332
- // Check if already processed (prevents race condition)
333
- if (processingState.get(msg.id)) {
334
- return;
335
- }
336
- processingState.set(msg.id, true);
337
-
338
- // Clear timeout to prevent double cleanup
339
- clearTimeout(timeoutId);
340
- shell.stdout?.off('data', onData);
341
-
342
- try {
343
- // Read results
344
- const stdout = fs.readFileSync(outFile, 'utf8');
345
- const stderr = fs.readFileSync(errFile, 'utf8');
346
- const exitCode = parseInt(fs.readFileSync(exitFile, 'utf8').trim());
347
-
348
- // Send response
349
- sendResponse({
350
- type: 'result',
351
- id: msg.id,
352
- stdout,
353
- stderr,
354
- exitCode
355
- });
356
-
357
- // Atomic cleanup of temp files
358
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
359
- } catch (error) {
360
- sendResponse({
361
- type: 'error',
362
- id: msg.id,
363
- error: `Failed to read output: ${error instanceof Error ? error.message : String(error)}`
364
- });
365
-
366
- // Still try to clean up files on error
367
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
368
- }
369
- }
370
- };
371
-
372
- // Set up timeout
373
- const timeoutId = setTimeout(() => {
374
- // Check if already processed
375
- if (!processingState.get(msg.id)) {
376
- processingState.set(msg.id, true);
377
-
378
- shell.stdout?.off('data', onData);
379
- sendResponse({
380
- type: 'error',
381
- id: msg.id,
382
- error: `Command timeout after ${COMMAND_TIMEOUT_MS/1000} seconds`
383
- });
384
-
385
- // Atomic cleanup of temp files
386
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
387
- }
388
- }, COMMAND_TIMEOUT_MS);
389
-
390
- // Listen for completion marker
391
- shell.stdout?.on('data', onData);
392
-
393
- // Execute the script
394
- shell.stdin?.write(execScript);
395
- }
396
-
397
- /**
398
- * Handle a streaming exec command
399
- */
400
- async function handleExecStreamCommand(msg: ControlMessage): Promise<void> {
401
- if (!shellAlive) {
402
- sendResponse({
403
- type: 'stream_event',
404
- id: msg.id,
405
- event: {
406
- type: 'error',
407
- timestamp: new Date().toISOString(),
408
- command: msg.command,
409
- error: 'Shell is not alive'
410
- }
411
- });
412
- return;
413
- }
414
-
415
- if (!msg.command) {
416
- sendResponse({
417
- type: 'stream_event',
418
- id: msg.id,
419
- event: {
420
- type: 'error',
421
- timestamp: new Date().toISOString(),
422
- error: 'No command provided'
423
- }
424
- });
425
- return;
426
- }
427
-
428
- // Create secure temp files for this command
429
- const cmdFile = createSecureTempFile('cmd', msg.id);
430
- const outFile = createSecureTempFile('out', msg.id);
431
- const errFile = createSecureTempFile('err', msg.id);
432
- const exitFile = createSecureTempFile('exit', msg.id);
433
-
434
- // Track these files as active
435
- activeFiles.add(cmdFile);
436
- activeFiles.add(outFile);
437
- activeFiles.add(errFile);
438
- activeFiles.add(exitFile);
439
-
440
- // Initialize processing state
441
- processingState.set(msg.id, false);
442
-
443
- // Write command to file securely (file already has proper permissions)
444
- fs.writeFileSync(cmdFile, msg.command, { encoding: 'utf8', mode: TEMP_FILE_PERMISSIONS });
445
-
446
- // Track output sizes for incremental streaming
447
- let stdoutSize = 0;
448
- let stderrSize = 0;
449
-
450
- // Send start event
451
- sendResponse({
452
- type: 'stream_event',
453
- id: msg.id,
454
- event: {
455
- type: 'start',
456
- timestamp: new Date().toISOString(),
457
- command: msg.command
458
- }
459
- });
460
-
461
- // Set up streaming interval to check for new output
462
- const streamingInterval = setInterval(() => {
463
- try {
464
- // Check for new stdout
465
- if (fs.existsSync(outFile)) {
466
- const currentStdout = fs.readFileSync(outFile, 'utf8');
467
- if (currentStdout.length > stdoutSize) {
468
- const newData = currentStdout.slice(stdoutSize);
469
- stdoutSize = currentStdout.length;
470
- sendResponse({
471
- type: 'stream_event',
472
- id: msg.id,
473
- event: {
474
- type: 'stdout',
475
- timestamp: new Date().toISOString(),
476
- data: newData,
477
- command: msg.command
478
- }
479
- });
480
- }
481
- }
482
-
483
- // Check for new stderr
484
- if (fs.existsSync(errFile)) {
485
- const currentStderr = fs.readFileSync(errFile, 'utf8');
486
- if (currentStderr.length > stderrSize) {
487
- const newData = currentStderr.slice(stderrSize);
488
- stderrSize = currentStderr.length;
489
- sendResponse({
490
- type: 'stream_event',
491
- id: msg.id,
492
- event: {
493
- type: 'stderr',
494
- timestamp: new Date().toISOString(),
495
- data: newData,
496
- command: msg.command
497
- }
498
- });
499
- }
500
- }
501
- } catch (e) {
502
- // Files might not exist yet, that's ok
503
- }
504
- }, 100); // Check every 100ms for real-time feel
505
-
506
- // Build and execute the script
507
- const execScript = buildExecScript(cmdFile, outFile, errFile, exitFile, msg.id, msg.cwd, 'STREAM_DONE');
508
-
509
- // Set up completion handler
510
- const onStreamData = (chunk: Buffer) => {
511
- const output = chunk.toString();
512
- if (output.includes(`STREAM_DONE:${msg.id}`)) {
513
- // Check if already processed
514
- if (processingState.get(msg.id)) {
515
- return;
516
- }
517
- processingState.set(msg.id, true);
518
-
519
- // Clear timeout and interval
520
- clearTimeout(streamTimeoutId);
521
- clearInterval(streamingInterval);
522
- shell.stdout?.off('data', onStreamData);
523
-
524
- try {
525
- // Read final results
526
- const stdout = fs.existsSync(outFile) ? fs.readFileSync(outFile, 'utf8') : '';
527
- const stderr = fs.existsSync(errFile) ? fs.readFileSync(errFile, 'utf8') : '';
528
- const exitCode = fs.existsSync(exitFile) ? parseInt(fs.readFileSync(exitFile, 'utf8').trim()) : 1;
529
-
530
- // Send any remaining output
531
- if (stdout.length > stdoutSize) {
532
- const newData = stdout.slice(stdoutSize);
533
- sendResponse({
534
- type: 'stream_event',
535
- id: msg.id,
536
- event: {
537
- type: 'stdout',
538
- timestamp: new Date().toISOString(),
539
- data: newData,
540
- command: msg.command
541
- }
542
- });
543
- }
544
-
545
- if (stderr.length > stderrSize) {
546
- const newData = stderr.slice(stderrSize);
547
- sendResponse({
548
- type: 'stream_event',
549
- id: msg.id,
550
- event: {
551
- type: 'stderr',
552
- timestamp: new Date().toISOString(),
553
- data: newData,
554
- command: msg.command
555
- }
556
- });
557
- }
558
-
559
- // Send completion event
560
- sendResponse({
561
- type: 'stream_event',
562
- id: msg.id,
563
- event: {
564
- type: 'complete',
565
- timestamp: new Date().toISOString(),
566
- command: msg.command,
567
- exitCode: exitCode,
568
- result: {
569
- stdout,
570
- stderr,
571
- exitCode,
572
- success: exitCode === 0
573
- }
574
- }
575
- });
576
-
577
- // Atomic cleanup of temp files
578
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
579
- } catch (error) {
580
- sendResponse({
581
- type: 'stream_event',
582
- id: msg.id,
583
- event: {
584
- type: 'error',
585
- timestamp: new Date().toISOString(),
586
- command: msg.command,
587
- error: `Failed to read output: ${error instanceof Error ? error.message : String(error)}`
588
- }
589
- });
590
-
591
- // Clean up files on error
592
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
593
- }
594
- }
595
- };
596
-
597
- // Set up timeout
598
- const streamTimeoutId = setTimeout(() => {
599
- // Check if already processed
600
- if (!processingState.get(msg.id)) {
601
- processingState.set(msg.id, true);
602
-
603
- clearInterval(streamingInterval);
604
- shell.stdout?.off('data', onStreamData);
605
-
606
- sendResponse({
607
- type: 'stream_event',
608
- id: msg.id,
609
- event: {
610
- type: 'error',
611
- timestamp: new Date().toISOString(),
612
- command: msg.command,
613
- error: `Command timeout after ${COMMAND_TIMEOUT_MS/1000} seconds`
614
- }
615
- });
616
-
617
- // Atomic cleanup of temp files
618
- atomicCleanupFiles([cmdFile, outFile, errFile, exitFile], msg.id);
619
- }
620
- }, COMMAND_TIMEOUT_MS);
621
-
622
- // Listen for completion marker
623
- shell.stdout?.on('data', onStreamData);
624
-
625
- // Execute the script
626
- shell.stdin?.write(execScript);
627
- }
628
-
629
- /**
630
- * Handle incoming control messages from parent process
631
- */
632
- async function handleControlMessage(msg: ControlMessage): Promise<void> {
633
- switch (msg.type) {
634
- case 'exit':
635
- shell.kill('SIGTERM');
636
- process.exit(0);
637
- break;
638
-
639
- case 'exec':
640
- await handleExecCommand(msg);
641
- break;
642
-
643
- case 'exec_stream':
644
- await handleExecStreamCommand(msg);
645
- break;
646
-
647
- default:
648
- logError(`Unknown message type: ${(msg as ControlMessage).type}`);
649
- }
650
- }
651
-
652
- /**
653
- * Initialize the shell process
654
- *
655
- * Creates either an isolated shell using 'unshare' or a regular bash shell.
656
- * Isolation uses Linux namespaces (PID) to prevent the shell from:
657
- * - Seeing control plane processes (Bun server)
658
- * - Accessing platform secrets in /proc/1/environ
659
- * - Hijacking control plane ports
660
- */
661
- function initializeShell(): void {
662
- logError(`Starting control process for session '${sessionId}' (isolation: ${isIsolated})`);
663
-
664
- // Build shell command based on isolation requirements
665
- let shellCommand: string[];
666
- if (isIsolated) {
667
- // Use unshare for PID namespace isolation
668
- // --pid: Create new PID namespace (processes can't see host processes)
669
- // --fork: Fork before exec (required for PID namespace to work properly)
670
- // --mount-proc: Mount new /proc filesystem (hides host process info)
671
- shellCommand = ['unshare', '--pid', '--fork', '--mount-proc', 'bash', '--norc'];
672
- logError('Using namespace isolation via unshare');
673
- } else {
674
- // Regular bash shell without isolation
675
- shellCommand = ['bash', '--norc'];
676
- logError('Running without namespace isolation');
677
- }
678
-
679
- // Spawn the shell process
680
- shell = spawn(shellCommand[0], shellCommand.slice(1), {
681
- stdio: ['pipe', 'pipe', 'pipe'],
682
- cwd: sessionCwd,
683
- env: process.env
684
- });
685
-
686
- // Handle shell spawn errors (e.g., unshare not found or permission denied)
687
- shell.on('error', (error: Error & { code?: string }) => {
688
- logError('Shell spawn error:', error);
689
- shellAlive = false;
690
-
691
- // Provide helpful error messages based on the error
692
- let errorMessage = error.message;
693
- if (isIsolated && error.code === 'ENOENT') {
694
- errorMessage = 'unshare command not found. Namespace isolation requires Linux with util-linux installed.';
695
- } else if (isIsolated && (error.code === 'EPERM' || error.code === 'EACCES')) {
696
- errorMessage = 'Permission denied for namespace isolation. CAP_SYS_ADMIN capability required.';
697
- }
698
-
699
- sendResponse({
700
- type: 'error',
701
- id: 'shell',
702
- error: errorMessage
703
- });
704
-
705
- // If isolation failed but not required, try fallback to regular bash
706
- if (isIsolated && process.env.ISOLATION_FALLBACK !== '0') {
707
- logError('Attempting fallback to non-isolated shell...');
708
- isIsolated = false;
709
- initializeShell(); // Recursive call with isolation disabled
710
- return;
711
- }
712
-
713
- process.exit(1);
714
- });
715
-
716
- // Handle shell exit
717
- shell.on('exit', (code) => {
718
- logError(`Shell exited with code ${code}`);
719
- shellAlive = false;
720
-
721
- // Clean up any remaining temp files atomically
722
- const filesToClean = Array.from(activeFiles);
723
- filesToClean.forEach(file => {
724
- try {
725
- fs.unlinkSync(file);
726
- activeFiles.delete(file);
727
- } catch (e) {
728
- // Ignore errors during final cleanup
729
- }
730
- });
731
-
732
- process.exit(code || 1);
733
- });
734
-
735
- // Mark shell as alive if successfully spawned
736
- if (shell) {
737
- shellAlive = true;
738
- }
739
-
740
- // Send ready signal once shell is spawned
741
- sendResponse({ type: 'ready', id: 'init' });
742
- }
743
-
744
- /**
745
- * Main entry point
746
- */
747
- function main(): void {
748
- // Initialize secure temp directory first
749
- initializeSecureTempDir();
750
-
751
- // Initialize the shell
752
- initializeShell();
753
-
754
- // Set up periodic cleanup
755
- setInterval(cleanupTempFiles, CLEANUP_INTERVAL_MS);
756
-
757
- // Handle stdin input from parent process
758
- process.stdin.on('data', async (data) => {
759
- const lines = data.toString().split('\n');
760
- for (const line of lines) {
761
- if (!line.trim()) continue;
762
-
763
- try {
764
- const msg = JSON.parse(line) as ControlMessage;
765
- await handleControlMessage(msg);
766
- } catch (e) {
767
- logError('Failed to parse command:', e);
768
- }
769
- }
770
- });
771
-
772
- // Cleanup on exit
773
- process.on('exit', () => {
774
- activeFiles.forEach(file => {
775
- try { fs.unlinkSync(file); } catch (e) {}
776
- });
777
- });
778
-
779
- // Keep process alive
780
- process.stdin.resume();
781
- }
782
-
783
- // Start the control process
784
- main();