@cloudflare/sandbox 0.3.7 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +44 -0
- package/CHANGELOG.md +8 -10
- package/Dockerfile +82 -18
- package/README.md +89 -824
- package/dist/chunk-53JFOF7F.js +2352 -0
- package/dist/chunk-53JFOF7F.js.map +1 -0
- package/dist/chunk-BFVUNTP4.js +104 -0
- package/dist/chunk-BFVUNTP4.js.map +1 -0
- package/dist/{chunk-NNGBXDMY.js → chunk-EKSWCBCA.js} +3 -6
- package/dist/chunk-EKSWCBCA.js.map +1 -0
- package/dist/chunk-JXZMAU2C.js +559 -0
- package/dist/chunk-JXZMAU2C.js.map +1 -0
- package/dist/{chunk-6UAWTJ5S.js → chunk-Z532A7QC.js} +13 -20
- package/dist/{chunk-6UAWTJ5S.js.map → chunk-Z532A7QC.js.map} +1 -1
- package/dist/file-stream.d.ts +16 -38
- package/dist/file-stream.js +1 -2
- package/dist/index.d.ts +6 -5
- package/dist/index.js +45 -38
- package/dist/interpreter.d.ts +3 -3
- package/dist/interpreter.js +2 -2
- package/dist/request-handler.d.ts +4 -3
- package/dist/request-handler.js +4 -7
- package/dist/sandbox-D9K2ypln.d.ts +583 -0
- package/dist/sandbox.d.ts +3 -3
- package/dist/sandbox.js +4 -7
- package/dist/security.d.ts +4 -3
- package/dist/security.js +3 -3
- package/dist/sse-parser.js +1 -1
- package/package.json +12 -4
- package/src/clients/base-client.ts +280 -0
- package/src/clients/command-client.ts +115 -0
- package/src/clients/file-client.ts +269 -0
- package/src/clients/git-client.ts +92 -0
- package/src/clients/index.ts +63 -0
- package/src/{interpreter-client.ts → clients/interpreter-client.ts} +148 -171
- package/src/clients/port-client.ts +105 -0
- package/src/clients/process-client.ts +177 -0
- package/src/clients/sandbox-client.ts +41 -0
- package/src/clients/types.ts +84 -0
- package/src/clients/utility-client.ts +94 -0
- package/src/errors/adapter.ts +180 -0
- package/src/errors/classes.ts +469 -0
- package/src/errors/index.ts +105 -0
- package/src/file-stream.ts +119 -117
- package/src/index.ts +81 -69
- package/src/interpreter.ts +17 -8
- package/src/request-handler.ts +69 -43
- package/src/sandbox.ts +694 -533
- package/src/security.ts +14 -23
- package/src/sse-parser.ts +4 -8
- package/startup.sh +3 -0
- package/tests/base-client.test.ts +328 -0
- package/tests/command-client.test.ts +407 -0
- package/tests/file-client.test.ts +643 -0
- package/tests/file-stream.test.ts +306 -0
- package/tests/git-client.test.ts +328 -0
- package/tests/port-client.test.ts +301 -0
- package/tests/process-client.test.ts +658 -0
- package/tests/sandbox.test.ts +465 -0
- package/tests/sse-parser.test.ts +290 -0
- package/tests/utility-client.test.ts +266 -0
- package/tests/wrangler.jsonc +35 -0
- package/tsconfig.json +9 -1
- package/vitest.config.ts +31 -0
- package/container_src/bun.lock +0 -76
- package/container_src/circuit-breaker.ts +0 -121
- package/container_src/control-process.ts +0 -784
- package/container_src/handler/exec.ts +0 -185
- package/container_src/handler/file.ts +0 -457
- package/container_src/handler/git.ts +0 -130
- package/container_src/handler/ports.ts +0 -314
- package/container_src/handler/process.ts +0 -568
- package/container_src/handler/session.ts +0 -92
- package/container_src/index.ts +0 -601
- package/container_src/interpreter-service.ts +0 -276
- package/container_src/isolation.ts +0 -1213
- package/container_src/mime-processor.ts +0 -255
- package/container_src/package.json +0 -18
- package/container_src/runtime/executors/javascript/node_executor.ts +0 -123
- package/container_src/runtime/executors/python/ipython_executor.py +0 -338
- package/container_src/runtime/executors/typescript/ts_executor.ts +0 -138
- package/container_src/runtime/process-pool.ts +0 -464
- package/container_src/shell-escape.ts +0 -42
- package/container_src/startup.sh +0 -11
- package/container_src/types.ts +0 -131
- package/dist/chunk-32UDXUPC.js +0 -671
- package/dist/chunk-32UDXUPC.js.map +0 -1
- package/dist/chunk-5DILEXGY.js +0 -85
- package/dist/chunk-5DILEXGY.js.map +0 -1
- package/dist/chunk-D3U63BZP.js +0 -240
- package/dist/chunk-D3U63BZP.js.map +0 -1
- package/dist/chunk-FXYPFGOZ.js +0 -129
- package/dist/chunk-FXYPFGOZ.js.map +0 -1
- package/dist/chunk-JTKON2SH.js +0 -113
- package/dist/chunk-JTKON2SH.js.map +0 -1
- package/dist/chunk-NNGBXDMY.js.map +0 -1
- package/dist/chunk-SQLJNZ3K.js +0 -674
- package/dist/chunk-SQLJNZ3K.js.map +0 -1
- package/dist/chunk-W7TVRPBG.js +0 -108
- package/dist/chunk-W7TVRPBG.js.map +0 -1
- package/dist/client-B3RUab0s.d.ts +0 -225
- package/dist/client.d.ts +0 -4
- package/dist/client.js +0 -7
- package/dist/client.js.map +0 -1
- package/dist/errors.d.ts +0 -95
- package/dist/errors.js +0 -27
- package/dist/errors.js.map +0 -1
- package/dist/interpreter-client.d.ts +0 -4
- package/dist/interpreter-client.js +0 -9
- package/dist/interpreter-client.js.map +0 -1
- package/dist/interpreter-types.d.ts +0 -259
- package/dist/interpreter-types.js +0 -9
- package/dist/interpreter-types.js.map +0 -1
- package/dist/types.d.ts +0 -453
- package/dist/types.js +0 -45
- package/dist/types.js.map +0 -1
- package/src/client.ts +0 -1048
- package/src/errors.ts +0 -219
- package/src/interpreter-types.ts +0 -390
- 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();
|