@treesap/sandbox 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +495 -0
  3. package/dist/api-server.d.ts +41 -0
  4. package/dist/api-server.d.ts.map +1 -0
  5. package/dist/api-server.js +536 -0
  6. package/dist/api-server.js.map +1 -0
  7. package/dist/auth-middleware.d.ts +31 -0
  8. package/dist/auth-middleware.d.ts.map +1 -0
  9. package/dist/auth-middleware.js +35 -0
  10. package/dist/auth-middleware.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +65 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/client.d.ts +137 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +412 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/file-service.d.ts +94 -0
  20. package/dist/file-service.d.ts.map +1 -0
  21. package/dist/file-service.js +203 -0
  22. package/dist/file-service.js.map +1 -0
  23. package/dist/http-exposure-service.d.ts +71 -0
  24. package/dist/http-exposure-service.d.ts.map +1 -0
  25. package/dist/http-exposure-service.js +172 -0
  26. package/dist/http-exposure-service.js.map +1 -0
  27. package/dist/index.d.ts +59 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +66 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/sandbox-manager.d.ts +76 -0
  32. package/dist/sandbox-manager.d.ts.map +1 -0
  33. package/dist/sandbox-manager.js +161 -0
  34. package/dist/sandbox-manager.js.map +1 -0
  35. package/dist/sandbox.d.ts +118 -0
  36. package/dist/sandbox.d.ts.map +1 -0
  37. package/dist/sandbox.js +303 -0
  38. package/dist/sandbox.js.map +1 -0
  39. package/dist/server.d.ts +7 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +240 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/stream-service.d.ts +35 -0
  44. package/dist/stream-service.d.ts.map +1 -0
  45. package/dist/stream-service.js +136 -0
  46. package/dist/stream-service.js.map +1 -0
  47. package/dist/terminal.d.ts +46 -0
  48. package/dist/terminal.d.ts.map +1 -0
  49. package/dist/terminal.js +264 -0
  50. package/dist/terminal.js.map +1 -0
  51. package/dist/websocket.d.ts +48 -0
  52. package/dist/websocket.d.ts.map +1 -0
  53. package/dist/websocket.js +332 -0
  54. package/dist/websocket.js.map +1 -0
  55. package/package.json +59 -0
  56. package/src/api-server.ts +658 -0
  57. package/src/auth-middleware.ts +65 -0
  58. package/src/cli.ts +71 -0
  59. package/src/client.ts +537 -0
  60. package/src/file-service.ts +273 -0
  61. package/src/http-exposure-service.ts +232 -0
  62. package/src/index.ts +101 -0
  63. package/src/sandbox-manager.ts +202 -0
  64. package/src/sandbox.ts +396 -0
  65. package/src/stream-service.ts +174 -0
  66. package/tsconfig.json +37 -0
@@ -0,0 +1,202 @@
1
+ import { Sandbox, SandboxConfig } from './sandbox';
2
+ import { EventEmitter } from 'events';
3
+ import * as path from 'path';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ export interface SandboxManagerConfig {
7
+ basePath?: string;
8
+ maxSandboxes?: number;
9
+ defaultTimeout?: number;
10
+ autoCleanup?: boolean;
11
+ cleanupInterval?: number;
12
+ maxIdleTime?: number;
13
+ }
14
+
15
+ /**
16
+ * Manages multiple sandbox instances
17
+ */
18
+ export class SandboxManager extends EventEmitter {
19
+ private sandboxes: Map<string, Sandbox> = new Map();
20
+ private lastActivity: Map<string, number> = new Map();
21
+ private config: SandboxManagerConfig;
22
+ private cleanupIntervalId?: NodeJS.Timeout;
23
+
24
+ constructor(config: SandboxManagerConfig = {}) {
25
+ super();
26
+ this.config = {
27
+ basePath: config.basePath || path.join(process.cwd(), '.sandboxes'),
28
+ maxSandboxes: config.maxSandboxes || 100,
29
+ defaultTimeout: config.defaultTimeout || 300000, // 5 minutes
30
+ autoCleanup: config.autoCleanup !== false, // Default true
31
+ cleanupInterval: config.cleanupInterval || 60000, // 1 minute
32
+ maxIdleTime: config.maxIdleTime || 1800000, // 30 minutes
33
+ };
34
+
35
+ // Ensure basePath is always set
36
+ if (!this.config.basePath) {
37
+ this.config.basePath = path.join(process.cwd(), '.sandboxes');
38
+ }
39
+
40
+ if (this.config.autoCleanup) {
41
+ this.startCleanupTask();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Create a new sandbox
47
+ */
48
+ async createSandbox(config: SandboxConfig = {}): Promise<Sandbox> {
49
+ // Check sandbox limit
50
+ if (this.config.maxSandboxes && this.sandboxes.size >= this.config.maxSandboxes) {
51
+ throw new Error(`Maximum sandbox limit reached (${this.config.maxSandboxes})`);
52
+ }
53
+
54
+ // Generate ID first if not provided
55
+ const sandboxId = config.id || uuidv4();
56
+
57
+ // Create sandbox with base path
58
+ const sandboxConfig: SandboxConfig = {
59
+ ...config,
60
+ id: sandboxId,
61
+ workDir: config.workDir || path.join(this.config.basePath!, sandboxId),
62
+ timeout: config.timeout || this.config.defaultTimeout,
63
+ };
64
+
65
+ const sandbox = new Sandbox(sandboxConfig);
66
+ await sandbox.initialize();
67
+
68
+ // Track sandbox
69
+ this.sandboxes.set(sandbox.id, sandbox);
70
+ this.lastActivity.set(sandbox.id, Date.now());
71
+
72
+ // Listen for sandbox events to track activity
73
+ sandbox.on('output', () => this.updateActivity(sandbox.id));
74
+ sandbox.on('exec_complete', () => this.updateActivity(sandbox.id));
75
+ sandbox.on('process_started', () => this.updateActivity(sandbox.id));
76
+
77
+ this.emit('sandbox_created', { id: sandbox.id });
78
+
79
+ return sandbox;
80
+ }
81
+
82
+ /**
83
+ * Get a sandbox by ID
84
+ */
85
+ getSandbox(id: string): Sandbox | undefined {
86
+ const sandbox = this.sandboxes.get(id);
87
+ if (sandbox) {
88
+ this.updateActivity(id);
89
+ }
90
+ return sandbox;
91
+ }
92
+
93
+ /**
94
+ * List all sandboxes
95
+ */
96
+ listSandboxes(): Array<{ id: string; status: ReturnType<Sandbox['getStatus']> }> {
97
+ return Array.from(this.sandboxes.entries()).map(([id, sandbox]) => ({
98
+ id,
99
+ status: sandbox.getStatus(),
100
+ }));
101
+ }
102
+
103
+ /**
104
+ * Destroy a sandbox
105
+ */
106
+ async destroySandbox(id: string, options: { cleanup?: boolean } = {}): Promise<void> {
107
+ const sandbox = this.sandboxes.get(id);
108
+ if (!sandbox) {
109
+ throw new Error(`Sandbox ${id} not found`);
110
+ }
111
+
112
+ await sandbox.destroy(options);
113
+ this.sandboxes.delete(id);
114
+ this.lastActivity.delete(id);
115
+
116
+ this.emit('sandbox_destroyed', { id });
117
+ }
118
+
119
+ /**
120
+ * Destroy all sandboxes
121
+ */
122
+ async destroyAll(options: { cleanup?: boolean } = {}): Promise<void> {
123
+ const destroyPromises = Array.from(this.sandboxes.keys()).map((id) =>
124
+ this.destroySandbox(id, options).catch((err) => {
125
+ console.error(`Error destroying sandbox ${id}:`, err);
126
+ })
127
+ );
128
+
129
+ await Promise.all(destroyPromises);
130
+ }
131
+
132
+ /**
133
+ * Get manager statistics
134
+ */
135
+ getStats() {
136
+ const sandboxList = this.listSandboxes();
137
+ const totalProcesses = sandboxList.reduce(
138
+ (sum, { status }) => sum + status.processCount,
139
+ 0
140
+ );
141
+
142
+ return {
143
+ totalSandboxes: this.sandboxes.size,
144
+ maxSandboxes: this.config.maxSandboxes,
145
+ totalProcesses,
146
+ basePath: this.config.basePath,
147
+ autoCleanup: this.config.autoCleanup,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Start periodic cleanup task
153
+ */
154
+ private startCleanupTask(): void {
155
+ this.cleanupIntervalId = setInterval(() => {
156
+ this.cleanupIdleSandboxes();
157
+ }, this.config.cleanupInterval);
158
+
159
+ // Don't keep process alive for cleanup
160
+ this.cleanupIntervalId.unref();
161
+ }
162
+
163
+ /**
164
+ * Clean up idle sandboxes
165
+ */
166
+ private async cleanupIdleSandboxes(): Promise<void> {
167
+ const now = Date.now();
168
+ const maxIdleTime = this.config.maxIdleTime!;
169
+
170
+ const idleSandboxes = Array.from(this.lastActivity.entries())
171
+ .filter(([_, lastActive]) => now - lastActive > maxIdleTime)
172
+ .map(([id]) => id);
173
+
174
+ for (const id of idleSandboxes) {
175
+ try {
176
+ await this.destroySandbox(id, { cleanup: true });
177
+ this.emit('sandbox_cleaned_up', { id, reason: 'idle' });
178
+ } catch (error) {
179
+ console.error(`Error cleaning up sandbox ${id}:`, error);
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Update last activity time for a sandbox
186
+ */
187
+ private updateActivity(id: string): void {
188
+ this.lastActivity.set(id, Date.now());
189
+ }
190
+
191
+ /**
192
+ * Shutdown the manager (cleanup all sandboxes)
193
+ */
194
+ async shutdown(options: { cleanup?: boolean } = {}): Promise<void> {
195
+ if (this.cleanupIntervalId) {
196
+ clearInterval(this.cleanupIntervalId);
197
+ }
198
+
199
+ await this.destroyAll(options);
200
+ this.removeAllListeners();
201
+ }
202
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,396 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { EventEmitter } from 'events';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+
7
+ export interface SandboxConfig {
8
+ id?: string;
9
+ workDir?: string;
10
+ env?: Record<string, string>;
11
+ timeout?: number;
12
+ maxProcesses?: number;
13
+ }
14
+
15
+ export interface ProcessInfo {
16
+ id: string;
17
+ pid: number;
18
+ command: string;
19
+ status: 'running' | 'completed' | 'failed';
20
+ startTime: number;
21
+ exitCode?: number;
22
+ }
23
+
24
+ export interface ExecOptions {
25
+ stream?: boolean;
26
+ onOutput?: (stream: 'stdout' | 'stderr', data: string) => void;
27
+ timeout?: number;
28
+ cwd?: string;
29
+ env?: Record<string, string>;
30
+ }
31
+
32
+ export interface ExecuteResponse {
33
+ success: boolean;
34
+ stdout: string;
35
+ stderr: string;
36
+ exitCode: number;
37
+ timedOut?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Represents a single isolated sandbox instance
42
+ * Each sandbox has its own working directory and process management
43
+ */
44
+ export class Sandbox extends EventEmitter {
45
+ public readonly id: string;
46
+ public readonly workDir: string;
47
+ public readonly createdAt: number;
48
+ private processes: Map<string, ProcessInfo> = new Map();
49
+ private runningProcesses: Map<string, ChildProcess> = new Map();
50
+ private config: SandboxConfig;
51
+ private destroyed: boolean = false;
52
+
53
+ constructor(config: SandboxConfig = {}) {
54
+ super();
55
+ this.id = config.id || uuidv4();
56
+ this.workDir = config.workDir || path.join(process.cwd(), '.sandboxes', this.id);
57
+ this.config = { ...config, env: config.env || {} };
58
+ this.createdAt = Date.now();
59
+ }
60
+
61
+ /**
62
+ * Initialize the sandbox (create working directory)
63
+ */
64
+ async initialize(): Promise<void> {
65
+ if (this.destroyed) {
66
+ throw new Error('Sandbox has been destroyed');
67
+ }
68
+
69
+ // Create working directory
70
+ await fs.mkdir(this.workDir, { recursive: true });
71
+
72
+ this.emit('initialized', { id: this.id, workDir: this.workDir });
73
+ }
74
+
75
+ /**
76
+ * Execute a command and return the complete result
77
+ */
78
+ async exec(command: string, options: ExecOptions = {}): Promise<ExecuteResponse> {
79
+ if (this.destroyed) {
80
+ throw new Error('Sandbox has been destroyed');
81
+ }
82
+
83
+ return new Promise((resolve) => {
84
+ let stdout = '';
85
+ let stderr = '';
86
+ let timedOut = false;
87
+
88
+ // Parse command and args
89
+ const [cmd, ...args] = this.parseCommand(command);
90
+
91
+ const childProcess = spawn(cmd, args, {
92
+ cwd: options.cwd || this.workDir,
93
+ env: { ...process.env, ...this.config.env, ...options.env },
94
+ shell: true,
95
+ });
96
+
97
+ // Handle timeout
98
+ let timeoutId: NodeJS.Timeout | undefined;
99
+ const timeout = options.timeout || this.config.timeout;
100
+ if (timeout) {
101
+ timeoutId = setTimeout(() => {
102
+ timedOut = true;
103
+ childProcess.kill('SIGTERM');
104
+
105
+ // Force kill after 5 seconds
106
+ setTimeout(() => {
107
+ if (!childProcess.killed) {
108
+ childProcess.kill('SIGKILL');
109
+ }
110
+ }, 5000);
111
+ }, timeout);
112
+ }
113
+
114
+ // Capture stdout
115
+ childProcess.stdout?.on('data', (data: Buffer) => {
116
+ const text = data.toString();
117
+ stdout += text;
118
+
119
+ if (options.stream && options.onOutput) {
120
+ options.onOutput('stdout', text);
121
+ }
122
+
123
+ this.emit('output', { stream: 'stdout', data: text });
124
+ });
125
+
126
+ // Capture stderr
127
+ childProcess.stderr?.on('data', (data: Buffer) => {
128
+ const text = data.toString();
129
+ stderr += text;
130
+
131
+ if (options.stream && options.onOutput) {
132
+ options.onOutput('stderr', text);
133
+ }
134
+
135
+ this.emit('output', { stream: 'stderr', data: text });
136
+ });
137
+
138
+ // Handle process completion
139
+ childProcess.on('close', (exitCode) => {
140
+ if (timeoutId) {
141
+ clearTimeout(timeoutId);
142
+ }
143
+
144
+ const response: ExecuteResponse = {
145
+ success: !timedOut && exitCode === 0,
146
+ stdout,
147
+ stderr,
148
+ exitCode: exitCode || 0,
149
+ timedOut,
150
+ };
151
+
152
+ this.emit('exec_complete', response);
153
+ resolve(response);
154
+ });
155
+
156
+ childProcess.on('error', (error) => {
157
+ if (timeoutId) {
158
+ clearTimeout(timeoutId);
159
+ }
160
+
161
+ const response: ExecuteResponse = {
162
+ success: false,
163
+ stdout,
164
+ stderr: stderr + '\n' + error.message,
165
+ exitCode: 1,
166
+ };
167
+
168
+ this.emit('exec_error', error);
169
+ resolve(response);
170
+ });
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Start a long-running background process
176
+ */
177
+ async startProcess(command: string, options: ExecOptions = {}): Promise<ProcessInfo> {
178
+ if (this.destroyed) {
179
+ throw new Error('Sandbox has been destroyed');
180
+ }
181
+
182
+ // Check max processes limit
183
+ if (this.config.maxProcesses && this.runningProcesses.size >= this.config.maxProcesses) {
184
+ throw new Error(`Maximum process limit reached (${this.config.maxProcesses})`);
185
+ }
186
+
187
+ const processId = uuidv4();
188
+ const [cmd, ...args] = this.parseCommand(command);
189
+
190
+ const childProcess = spawn(cmd, args, {
191
+ cwd: options.cwd || this.workDir,
192
+ env: { ...process.env, ...this.config.env, ...options.env },
193
+ shell: true,
194
+ });
195
+
196
+ const processInfo: ProcessInfo = {
197
+ id: processId,
198
+ pid: childProcess.pid!,
199
+ command,
200
+ status: 'running',
201
+ startTime: Date.now(),
202
+ };
203
+
204
+ this.processes.set(processId, processInfo);
205
+ this.runningProcesses.set(processId, childProcess);
206
+
207
+ // Listen for output
208
+ childProcess.stdout?.on('data', (data: Buffer) => {
209
+ this.emit('process_output', {
210
+ processId,
211
+ stream: 'stdout',
212
+ data: data.toString(),
213
+ });
214
+ });
215
+
216
+ childProcess.stderr?.on('data', (data: Buffer) => {
217
+ this.emit('process_output', {
218
+ processId,
219
+ stream: 'stderr',
220
+ data: data.toString(),
221
+ });
222
+ });
223
+
224
+ // Handle process exit
225
+ childProcess.on('close', (exitCode) => {
226
+ const info = this.processes.get(processId);
227
+ if (info) {
228
+ info.status = exitCode === 0 ? 'completed' : 'failed';
229
+ info.exitCode = exitCode || 0;
230
+ }
231
+
232
+ this.runningProcesses.delete(processId);
233
+ this.emit('process_exit', { processId, exitCode });
234
+ });
235
+
236
+ this.emit('process_started', processInfo);
237
+ return processInfo;
238
+ }
239
+
240
+ /**
241
+ * List all processes (running and completed)
242
+ */
243
+ listProcesses(): ProcessInfo[] {
244
+ return Array.from(this.processes.values());
245
+ }
246
+
247
+ /**
248
+ * Get information about a specific process
249
+ */
250
+ getProcess(processId: string): ProcessInfo | undefined {
251
+ return this.processes.get(processId);
252
+ }
253
+
254
+ /**
255
+ * Kill a specific process
256
+ */
257
+ async killProcess(processId: string, signal: string = 'SIGTERM'): Promise<void> {
258
+ const childProcess = this.runningProcesses.get(processId);
259
+ if (!childProcess) {
260
+ throw new Error(`Process ${processId} not found or already stopped`);
261
+ }
262
+
263
+ childProcess.kill(signal as NodeJS.Signals);
264
+
265
+ // Update process info
266
+ const info = this.processes.get(processId);
267
+ if (info) {
268
+ info.status = 'failed';
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Kill all running processes
274
+ */
275
+ async killAllProcesses(signal: string = 'SIGTERM'): Promise<void> {
276
+ const killPromises = Array.from(this.runningProcesses.keys()).map((processId) =>
277
+ this.killProcess(processId, signal).catch(() => {
278
+ // Ignore errors if process already stopped
279
+ })
280
+ );
281
+
282
+ await Promise.all(killPromises);
283
+ }
284
+
285
+ /**
286
+ * Get sandbox status
287
+ */
288
+ getStatus() {
289
+ return {
290
+ id: this.id,
291
+ workDir: this.workDir,
292
+ createdAt: this.createdAt,
293
+ uptime: Date.now() - this.createdAt,
294
+ processCount: this.runningProcesses.size,
295
+ totalProcesses: this.processes.size,
296
+ destroyed: this.destroyed,
297
+ };
298
+ }
299
+
300
+ // ============================================================================
301
+ // Environment Variable Management
302
+ // ============================================================================
303
+
304
+ /**
305
+ * Set a single environment variable
306
+ */
307
+ setEnv(key: string, value: string): void {
308
+ if (this.destroyed) {
309
+ throw new Error('Sandbox has been destroyed');
310
+ }
311
+ this.config.env![key] = value;
312
+ this.emit('env_changed', { key, value, action: 'set' });
313
+ }
314
+
315
+ /**
316
+ * Set multiple environment variables at once
317
+ */
318
+ setEnvBatch(variables: Record<string, string>): void {
319
+ if (this.destroyed) {
320
+ throw new Error('Sandbox has been destroyed');
321
+ }
322
+ for (const [key, value] of Object.entries(variables)) {
323
+ this.config.env![key] = value;
324
+ }
325
+ this.emit('env_changed', { variables, action: 'batch_set' });
326
+ }
327
+
328
+ /**
329
+ * Get environment variable(s)
330
+ * If key is provided, returns the value for that key
331
+ * Otherwise returns all environment variables
332
+ */
333
+ getEnv(): Record<string, string>;
334
+ getEnv(key: string): string | undefined;
335
+ getEnv(key?: string): Record<string, string> | string | undefined {
336
+ if (key !== undefined) {
337
+ return this.config.env![key];
338
+ }
339
+ return { ...this.config.env };
340
+ }
341
+
342
+ /**
343
+ * Get list of environment variable names (for security, not exposing values)
344
+ */
345
+ getEnvKeys(): string[] {
346
+ return Object.keys(this.config.env!);
347
+ }
348
+
349
+ /**
350
+ * Unset (remove) an environment variable
351
+ */
352
+ unsetEnv(key: string): boolean {
353
+ if (this.destroyed) {
354
+ throw new Error('Sandbox has been destroyed');
355
+ }
356
+ if (key in this.config.env!) {
357
+ delete this.config.env![key];
358
+ this.emit('env_changed', { key, action: 'unset' });
359
+ return true;
360
+ }
361
+ return false;
362
+ }
363
+
364
+ /**
365
+ * Destroy the sandbox (kill all processes, optionally clean up files)
366
+ */
367
+ async destroy(options: { cleanup?: boolean } = {}): Promise<void> {
368
+ if (this.destroyed) {
369
+ return;
370
+ }
371
+
372
+ // Kill all running processes
373
+ await this.killAllProcesses('SIGKILL');
374
+
375
+ // Clean up files if requested
376
+ if (options.cleanup) {
377
+ try {
378
+ await fs.rm(this.workDir, { recursive: true, force: true });
379
+ } catch (error) {
380
+ // Ignore cleanup errors
381
+ }
382
+ }
383
+
384
+ this.destroyed = true;
385
+ this.emit('destroyed', { id: this.id });
386
+ this.removeAllListeners();
387
+ }
388
+
389
+ /**
390
+ * Parse a command string into command and arguments
391
+ */
392
+ private parseCommand(command: string): string[] {
393
+ // Simple parsing - for more complex commands, shell: true handles it
394
+ return command.split(' ').filter(Boolean);
395
+ }
396
+ }