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