@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/CHANGELOG.md +32 -0
- package/Dockerfile +7 -24
- package/README.md +519 -24
- package/container_src/handler/exec.ts +338 -0
- package/container_src/handler/file.ts +844 -0
- package/container_src/handler/git.ts +182 -0
- package/container_src/handler/ports.ts +314 -0
- package/container_src/handler/process.ts +640 -0
- package/container_src/index.ts +82 -2917
- package/container_src/types.ts +108 -0
- package/package.json +5 -9
- package/src/client.ts +249 -1300
- package/src/index.ts +6 -0
- package/src/request-handler.ts +69 -20
- package/src/sandbox.ts +462 -70
- package/src/security.ts +113 -0
- package/src/sse-parser.ts +147 -0
- package/src/types.ts +386 -0
- package/tsconfig.json +1 -1
- package/tests/client.example.ts +0 -308
- package/tests/connection-test.ts +0 -81
- package/tests/simple-test.ts +0 -81
- package/tests/test1.ts +0 -281
- package/tests/test2.ts +0 -929
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
|
|
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
|
|
47
|
+
onCommandStart: (command) => {
|
|
29
48
|
console.log(
|
|
30
|
-
`[Container] Command started: ${command}
|
|
49
|
+
`[Container] Command started: ${command}`
|
|
31
50
|
);
|
|
32
51
|
},
|
|
33
|
-
onError: (error, _command
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
}
|