@cloudflare/sandbox 0.0.0-f5fcd52 → 0.0.0-fd5ec7f

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/src/sandbox.ts CHANGED
@@ -1,6 +1,25 @@
1
1
  import { Container, getContainer } from "@cloudflare/containers";
2
2
  import { HttpClient } from "./client";
3
3
  import { isLocalhostPattern } from "./request-handler";
4
+ import {
5
+ logSecurityEvent,
6
+ SecurityError,
7
+ sanitizeSandboxId,
8
+ validatePort
9
+ } from "./security";
10
+ import type {
11
+ ExecOptions,
12
+ ExecResult,
13
+ ISandbox,
14
+ Process,
15
+ ProcessOptions,
16
+ ProcessStatus,
17
+ StreamOptions
18
+ } from "./types";
19
+ import {
20
+ ProcessNotFoundError,
21
+ SandboxError
22
+ } from "./types";
4
23
 
5
24
  export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
6
25
  const stub = getContainer(ns, id);
@@ -11,26 +30,26 @@ export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
11
30
  return stub;
12
31
  }
13
32
 
14
- export class Sandbox<Env = unknown> extends Container<Env> {
33
+ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
34
+ defaultPort = 3000; // Default port for the container's Bun server
15
35
  sleepAfter = "3m"; // Sleep the sandbox if no requests are made in this timeframe
16
36
  client: HttpClient;
17
- private workerHostname: string | null = null;
18
37
  private sandboxName: string | null = null;
19
38
 
20
39
  constructor(ctx: DurableObjectState, env: Env) {
21
40
  super(ctx, env);
22
41
  this.client = new HttpClient({
23
- onCommandComplete: (success, exitCode, _stdout, _stderr, command, _args) => {
42
+ onCommandComplete: (success, exitCode, _stdout, _stderr, command) => {
24
43
  console.log(
25
44
  `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
26
45
  );
27
46
  },
28
- onCommandStart: (command, args) => {
47
+ onCommandStart: (command) => {
29
48
  console.log(
30
- `[Container] Command started: ${command} ${args.join(" ")}`
49
+ `[Container] Command started: ${command}`
31
50
  );
32
51
  },
33
- onError: (error, _command, _args) => {
52
+ onError: (error, _command) => {
34
53
  console.error(`[Container] Command error: ${error}`);
35
54
  },
36
55
  onOutput: (stream, data, _command) => {
@@ -76,16 +95,10 @@ export class Sandbox<Env = unknown> extends Container<Env> {
76
95
  console.log("Sandbox error:", error);
77
96
  }
78
97
 
79
- // Override fetch to capture the hostname and route to appropriate ports
98
+ // Override fetch to route internal container requests to appropriate ports
80
99
  override async fetch(request: Request): Promise<Response> {
81
100
  const url = new URL(request.url);
82
101
 
83
- // Capture the hostname from the first request
84
- if (!this.workerHostname) {
85
- this.workerHostname = url.hostname;
86
- console.log(`[Sandbox] Captured hostname: ${this.workerHostname}`);
87
- }
88
-
89
102
  // Capture and store the sandbox name from the header if present
90
103
  if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
91
104
  const name = request.headers.get('X-Sandbox-Name')!;
@@ -113,88 +126,389 @@ export class Sandbox<Env = unknown> extends Container<Env> {
113
126
  return 3000;
114
127
  }
115
128
 
116
- async exec(command: string, args: string[], options?: { stream?: boolean; background?: boolean }) {
117
- if (options?.stream) {
118
- return this.client.executeStream(command, args, undefined, options?.background);
129
+ // Enhanced exec method - always returns ExecResult with optional streaming
130
+ // This replaces the old exec method to match ISandbox interface
131
+ async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
132
+ const startTime = Date.now();
133
+ const timestamp = new Date().toISOString();
134
+
135
+ // Handle timeout
136
+ let timeoutId: NodeJS.Timeout | undefined;
137
+
138
+ try {
139
+ // Handle cancellation
140
+ if (options?.signal?.aborted) {
141
+ throw new Error('Operation was aborted');
142
+ }
143
+
144
+ let result: ExecResult;
145
+
146
+ if (options?.stream && options?.onOutput) {
147
+ // Streaming with callbacks - we need to collect the final result
148
+ result = await this.executeWithStreaming(command, options, startTime, timestamp);
149
+ } else {
150
+ // Regular execution
151
+ const response = await this.client.execute(
152
+ command,
153
+ {
154
+ sessionId: options?.sessionId,
155
+ cwd: options?.cwd,
156
+ env: options?.env,
157
+ }
158
+ );
159
+
160
+ const duration = Date.now() - startTime;
161
+ result = this.mapExecuteResponseToExecResult(response, duration, options?.sessionId);
162
+ }
163
+
164
+ // Call completion callback if provided
165
+ if (options?.onComplete) {
166
+ options.onComplete(result);
167
+ }
168
+
169
+ return result;
170
+ } catch (error) {
171
+ if (options?.onError && error instanceof Error) {
172
+ options.onError(error);
173
+ }
174
+ throw error;
175
+ } finally {
176
+ if (timeoutId) {
177
+ clearTimeout(timeoutId);
178
+ }
179
+ }
180
+ }
181
+
182
+ private async executeWithStreaming(
183
+ command: string,
184
+ options: ExecOptions,
185
+ startTime: number,
186
+ timestamp: string
187
+ ): Promise<ExecResult> {
188
+ let stdout = '';
189
+ let stderr = '';
190
+
191
+ try {
192
+ const stream = await this.client.executeCommandStream(command, options.sessionId);
193
+ const { parseSSEStream } = await import('./sse-parser');
194
+
195
+ for await (const event of parseSSEStream<import('./types').ExecEvent>(stream)) {
196
+ // Check for cancellation
197
+ if (options.signal?.aborted) {
198
+ throw new Error('Operation was aborted');
199
+ }
200
+
201
+ switch (event.type) {
202
+ case 'stdout':
203
+ case 'stderr':
204
+ if (event.data) {
205
+ // Update accumulated output
206
+ if (event.type === 'stdout') stdout += event.data;
207
+ if (event.type === 'stderr') stderr += event.data;
208
+
209
+ // Call user's callback
210
+ if (options.onOutput) {
211
+ options.onOutput(event.type, event.data);
212
+ }
213
+ }
214
+ break;
215
+
216
+ case 'complete': {
217
+ // Use result from complete event if available
218
+ const duration = Date.now() - startTime;
219
+ return event.result || {
220
+ success: event.exitCode === 0,
221
+ exitCode: event.exitCode || 0,
222
+ stdout,
223
+ stderr,
224
+ command,
225
+ duration,
226
+ timestamp,
227
+ sessionId: options.sessionId
228
+ };
229
+ }
230
+
231
+ case 'error':
232
+ throw new Error(event.error || 'Command execution failed');
233
+ }
234
+ }
235
+
236
+ // If we get here without a complete event, something went wrong
237
+ throw new Error('Stream ended without completion event');
238
+
239
+ } catch (error) {
240
+ if (options.signal?.aborted) {
241
+ throw new Error('Operation was aborted');
242
+ }
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ private mapExecuteResponseToExecResult(
248
+ response: import('./client').ExecuteResponse,
249
+ duration: number,
250
+ sessionId?: string
251
+ ): ExecResult {
252
+ return {
253
+ success: response.success,
254
+ exitCode: response.exitCode,
255
+ stdout: response.stdout,
256
+ stderr: response.stderr,
257
+ command: response.command,
258
+ duration,
259
+ timestamp: response.timestamp,
260
+ sessionId
261
+ };
262
+ }
263
+
264
+
265
+ // Background process management
266
+ async startProcess(command: string, options?: ProcessOptions): Promise<Process> {
267
+ // Use the new HttpClient method to start the process
268
+ try {
269
+ const response = await this.client.startProcess(command, {
270
+ processId: options?.processId,
271
+ sessionId: options?.sessionId,
272
+ timeout: options?.timeout,
273
+ env: options?.env,
274
+ cwd: options?.cwd,
275
+ encoding: options?.encoding,
276
+ autoCleanup: options?.autoCleanup
277
+ });
278
+
279
+ const process = response.process;
280
+ const processObj: Process = {
281
+ id: process.id,
282
+ pid: process.pid,
283
+ command: process.command,
284
+ status: process.status as ProcessStatus,
285
+ startTime: new Date(process.startTime),
286
+ endTime: undefined,
287
+ exitCode: undefined,
288
+ sessionId: process.sessionId,
289
+
290
+ async kill(): Promise<void> {
291
+ throw new Error('Method will be replaced');
292
+ },
293
+ async getStatus(): Promise<ProcessStatus> {
294
+ throw new Error('Method will be replaced');
295
+ },
296
+ async getLogs(): Promise<{ stdout: string; stderr: string }> {
297
+ throw new Error('Method will be replaced');
298
+ }
299
+ };
300
+
301
+ // Bind context properly
302
+ processObj.kill = async (signal?: string) => {
303
+ await this.killProcess(process.id, signal);
304
+ };
305
+
306
+ processObj.getStatus = async () => {
307
+ const current = await this.getProcess(process.id);
308
+ return current?.status || 'error';
309
+ };
310
+
311
+ processObj.getLogs = async () => {
312
+ const logs = await this.getProcessLogs(process.id);
313
+ return { stdout: logs.stdout, stderr: logs.stderr };
314
+ };
315
+
316
+ // Call onStart callback if provided
317
+ if (options?.onStart) {
318
+ options.onStart(processObj);
319
+ }
320
+
321
+ return processObj;
322
+
323
+ } catch (error) {
324
+ if (options?.onError && error instanceof Error) {
325
+ options.onError(error);
326
+ }
327
+
328
+ throw error;
329
+ }
330
+ }
331
+
332
+ async listProcesses(): Promise<Process[]> {
333
+ const response = await this.client.listProcesses();
334
+
335
+ return response.processes.map(processData => ({
336
+ id: processData.id,
337
+ pid: processData.pid,
338
+ command: processData.command,
339
+ status: processData.status,
340
+ startTime: new Date(processData.startTime),
341
+ endTime: processData.endTime ? new Date(processData.endTime) : undefined,
342
+ exitCode: processData.exitCode,
343
+ sessionId: processData.sessionId,
344
+
345
+ kill: async (signal?: string) => {
346
+ await this.killProcess(processData.id, signal);
347
+ },
348
+
349
+ getStatus: async () => {
350
+ const current = await this.getProcess(processData.id);
351
+ return current?.status || 'error';
352
+ },
353
+
354
+ getLogs: async () => {
355
+ const logs = await this.getProcessLogs(processData.id);
356
+ return { stdout: logs.stdout, stderr: logs.stderr };
357
+ }
358
+ }));
359
+ }
360
+
361
+ async getProcess(id: string): Promise<Process | null> {
362
+ const response = await this.client.getProcess(id);
363
+ if (!response.process) {
364
+ return null;
365
+ }
366
+
367
+ const processData = response.process;
368
+ return {
369
+ id: processData.id,
370
+ pid: processData.pid,
371
+ command: processData.command,
372
+ status: processData.status,
373
+ startTime: new Date(processData.startTime),
374
+ endTime: processData.endTime ? new Date(processData.endTime) : undefined,
375
+ exitCode: processData.exitCode,
376
+ sessionId: processData.sessionId,
377
+
378
+ kill: async (signal?: string) => {
379
+ await this.killProcess(processData.id, signal);
380
+ },
381
+
382
+ getStatus: async () => {
383
+ const current = await this.getProcess(processData.id);
384
+ return current?.status || 'error';
385
+ },
386
+
387
+ getLogs: async () => {
388
+ const logs = await this.getProcessLogs(processData.id);
389
+ return { stdout: logs.stdout, stderr: logs.stderr };
390
+ }
391
+ };
392
+ }
393
+
394
+ async killProcess(id: string, _signal?: string): Promise<void> {
395
+ try {
396
+ // Note: signal parameter is not currently supported by the HttpClient implementation
397
+ await this.client.killProcess(id);
398
+ } catch (error) {
399
+ if (error instanceof Error && error.message.includes('Process not found')) {
400
+ throw new ProcessNotFoundError(id);
401
+ }
402
+ throw new SandboxError(
403
+ `Failed to kill process ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
404
+ 'KILL_PROCESS_FAILED'
405
+ );
406
+ }
407
+ }
408
+
409
+ async killAllProcesses(): Promise<number> {
410
+ const response = await this.client.killAllProcesses();
411
+ return response.killedCount;
412
+ }
413
+
414
+ async cleanupCompletedProcesses(): Promise<number> {
415
+ // For now, this would need to be implemented as a container endpoint
416
+ // as we no longer maintain local process storage
417
+ // We'll return 0 as a placeholder until the container endpoint is added
418
+ return 0;
419
+ }
420
+
421
+ async getProcessLogs(id: string): Promise<{ stdout: string; stderr: string }> {
422
+ try {
423
+ const response = await this.client.getProcessLogs(id);
424
+ return {
425
+ stdout: response.stdout,
426
+ stderr: response.stderr
427
+ };
428
+ } catch (error) {
429
+ if (error instanceof Error && error.message.includes('Process not found')) {
430
+ throw new ProcessNotFoundError(id);
431
+ }
432
+ throw error;
433
+ }
434
+ }
435
+
436
+
437
+ // Streaming methods - return ReadableStream for RPC compatibility
438
+ async execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>> {
439
+ // Check for cancellation
440
+ if (options?.signal?.aborted) {
441
+ throw new Error('Operation was aborted');
119
442
  }
120
- return this.client.execute(command, args, undefined, options?.background);
443
+
444
+ // Get the stream from HttpClient (need to add this method)
445
+ const stream = await this.client.executeCommandStream(command, options?.sessionId);
446
+
447
+ // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
448
+ return stream;
449
+ }
450
+
451
+ async streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> {
452
+ // Check for cancellation
453
+ if (options?.signal?.aborted) {
454
+ throw new Error('Operation was aborted');
455
+ }
456
+
457
+ // Get the stream from HttpClient
458
+ const stream = await this.client.streamProcessLogs(processId);
459
+
460
+ // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
461
+ return stream;
121
462
  }
122
463
 
123
464
  async gitCheckout(
124
465
  repoUrl: string,
125
- options: { branch?: string; targetDir?: string; stream?: boolean }
466
+ options: { branch?: string; targetDir?: string }
126
467
  ) {
127
- if (options?.stream) {
128
- return this.client.gitCheckoutStream(
129
- repoUrl,
130
- options.branch,
131
- options.targetDir
132
- );
133
- }
134
468
  return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
135
469
  }
136
470
 
137
471
  async mkdir(
138
472
  path: string,
139
- options: { recursive?: boolean; stream?: boolean } = {}
473
+ options: { recursive?: boolean } = {}
140
474
  ) {
141
- if (options?.stream) {
142
- return this.client.mkdirStream(path, options.recursive);
143
- }
144
475
  return this.client.mkdir(path, options.recursive);
145
476
  }
146
477
 
147
478
  async writeFile(
148
479
  path: string,
149
480
  content: string,
150
- options: { encoding?: string; stream?: boolean } = {}
481
+ options: { encoding?: string } = {}
151
482
  ) {
152
- if (options?.stream) {
153
- return this.client.writeFileStream(path, content, options.encoding);
154
- }
155
483
  return this.client.writeFile(path, content, options.encoding);
156
484
  }
157
485
 
158
- async deleteFile(path: string, options: { stream?: boolean } = {}) {
159
- if (options?.stream) {
160
- return this.client.deleteFileStream(path);
161
- }
486
+ async deleteFile(path: string) {
162
487
  return this.client.deleteFile(path);
163
488
  }
164
489
 
165
490
  async renameFile(
166
491
  oldPath: string,
167
- newPath: string,
168
- options: { stream?: boolean } = {}
492
+ newPath: string
169
493
  ) {
170
- if (options?.stream) {
171
- return this.client.renameFileStream(oldPath, newPath);
172
- }
173
494
  return this.client.renameFile(oldPath, newPath);
174
495
  }
175
496
 
176
497
  async moveFile(
177
498
  sourcePath: string,
178
- destinationPath: string,
179
- options: { stream?: boolean } = {}
499
+ destinationPath: string
180
500
  ) {
181
- if (options?.stream) {
182
- return this.client.moveFileStream(sourcePath, destinationPath);
183
- }
184
501
  return this.client.moveFile(sourcePath, destinationPath);
185
502
  }
186
503
 
187
504
  async readFile(
188
505
  path: string,
189
- options: { encoding?: string; stream?: boolean } = {}
506
+ options: { encoding?: string } = {}
190
507
  ) {
191
- if (options?.stream) {
192
- return this.client.readFileStream(path, options.encoding);
193
- }
194
508
  return this.client.readFile(path, options.encoding);
195
509
  }
196
510
 
197
- async exposePort(port: number, options?: { name?: string }) {
511
+ async exposePort(port: number, options: { name?: string; hostname: string }) {
198
512
  await this.client.exposePort(port, options?.name);
199
513
 
200
514
  // We need the sandbox name to construct preview URLs
@@ -202,8 +516,7 @@ export class Sandbox<Env = unknown> extends Container<Env> {
202
516
  throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
203
517
  }
204
518
 
205
- const hostname = this.getHostname();
206
- const url = this.constructPreviewUrl(port, this.sandboxName, hostname);
519
+ const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname);
207
520
 
208
521
  return {
209
522
  url,
@@ -213,10 +526,21 @@ export class Sandbox<Env = unknown> extends Container<Env> {
213
526
  }
214
527
 
215
528
  async unexposePort(port: number) {
529
+ if (!validatePort(port)) {
530
+ logSecurityEvent('INVALID_PORT_UNEXPOSE', {
531
+ port
532
+ }, 'high');
533
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
534
+ }
535
+
216
536
  await this.client.unexposePort(port);
537
+
538
+ logSecurityEvent('PORT_UNEXPOSED', {
539
+ port
540
+ }, 'low');
217
541
  }
218
542
 
219
- async getExposedPorts() {
543
+ async getExposedPorts(hostname: string) {
220
544
  const response = await this.client.getExposedPorts();
221
545
 
222
546
  // We need the sandbox name to construct preview URLs
@@ -224,8 +548,6 @@ export class Sandbox<Env = unknown> extends Container<Env> {
224
548
  throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
225
549
  }
226
550
 
227
- const hostname = this.getHostname();
228
-
229
551
  return response.ports.map(port => ({
230
552
  url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname),
231
553
  port: port.port,
@@ -234,25 +556,95 @@ export class Sandbox<Env = unknown> extends Container<Env> {
234
556
  }));
235
557
  }
236
558
 
237
- private getHostname(): string {
238
- // Use the captured hostname or fall back to localhost for development
239
- return this.workerHostname || "localhost:8787";
240
- }
241
559
 
242
560
  private constructPreviewUrl(port: number, sandboxId: string, hostname: string): string {
243
- // Check if this is a localhost pattern
561
+ if (!validatePort(port)) {
562
+ logSecurityEvent('INVALID_PORT_REJECTED', {
563
+ port,
564
+ sandboxId,
565
+ hostname
566
+ }, 'high');
567
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
568
+ }
569
+
570
+ let sanitizedSandboxId: string;
571
+ try {
572
+ sanitizedSandboxId = sanitizeSandboxId(sandboxId);
573
+ } catch (error) {
574
+ logSecurityEvent('INVALID_SANDBOX_ID_REJECTED', {
575
+ sandboxId,
576
+ port,
577
+ hostname,
578
+ error: error instanceof Error ? error.message : 'Unknown error'
579
+ }, 'high');
580
+ throw error;
581
+ }
582
+
244
583
  const isLocalhost = isLocalhostPattern(hostname);
245
584
 
246
585
  if (isLocalhost) {
247
- // For local development, we need to use a different approach
248
- // Since subdomains don't work with localhost, we'll use the base URL
249
- // with a note that the user needs to handle routing differently
250
- return `http://${hostname}/preview/${port}/${sandboxId}`;
586
+ // Unified subdomain approach for localhost (RFC 6761)
587
+ const [host, portStr] = hostname.split(':');
588
+ const mainPort = portStr || '80';
589
+
590
+ // Use URL constructor for safe URL building
591
+ try {
592
+ const baseUrl = new URL(`http://${host}:${mainPort}`);
593
+ // Construct subdomain safely
594
+ const subdomainHost = `${port}-${sanitizedSandboxId}.${host}`;
595
+ baseUrl.hostname = subdomainHost;
596
+
597
+ const finalUrl = baseUrl.toString();
598
+
599
+ logSecurityEvent('PREVIEW_URL_CONSTRUCTED', {
600
+ port,
601
+ sandboxId: sanitizedSandboxId,
602
+ hostname,
603
+ resultUrl: finalUrl,
604
+ environment: 'localhost'
605
+ }, 'low');
606
+
607
+ return finalUrl;
608
+ } catch (error) {
609
+ logSecurityEvent('URL_CONSTRUCTION_FAILED', {
610
+ port,
611
+ sandboxId: sanitizedSandboxId,
612
+ hostname,
613
+ error: error instanceof Error ? error.message : 'Unknown error'
614
+ }, 'high');
615
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
616
+ }
251
617
  }
252
618
 
253
- // For all other domains (workers.dev, custom domains, etc.)
254
- // Use subdomain-based routing pattern
255
- const protocol = hostname.includes(":") ? "http" : "https";
256
- return `${protocol}://${port}-${sandboxId}.${hostname}`;
619
+ // Production subdomain logic - enforce HTTPS
620
+ try {
621
+ // Always use HTTPS for production (non-localhost)
622
+ const protocol = "https";
623
+ const baseUrl = new URL(`${protocol}://${hostname}`);
624
+
625
+ // Construct subdomain safely
626
+ const subdomainHost = `${port}-${sanitizedSandboxId}.${hostname}`;
627
+ baseUrl.hostname = subdomainHost;
628
+
629
+ const finalUrl = baseUrl.toString();
630
+
631
+ logSecurityEvent('PREVIEW_URL_CONSTRUCTED', {
632
+ port,
633
+ sandboxId: sanitizedSandboxId,
634
+ hostname,
635
+ resultUrl: finalUrl,
636
+ environment: 'production'
637
+ }, 'low');
638
+
639
+ return finalUrl;
640
+ } catch (error) {
641
+ logSecurityEvent('URL_CONSTRUCTION_FAILED', {
642
+ port,
643
+ sandboxId: sanitizedSandboxId,
644
+ hostname,
645
+ error: error instanceof Error ? error.message : 'Unknown error'
646
+ }, 'high');
647
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
648
+ }
257
649
  }
258
650
  }