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