@cloudflare/sandbox 0.0.0-12bbd12 → 0.0.0-153e416

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 (99) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile +107 -70
  3. package/README.md +88 -710
  4. package/dist/chunk-BFVUNTP4.js +104 -0
  5. package/dist/chunk-BFVUNTP4.js.map +1 -0
  6. package/dist/chunk-EKSWCBCA.js +86 -0
  7. package/dist/chunk-EKSWCBCA.js.map +1 -0
  8. package/dist/chunk-FE4PJSRB.js +7 -0
  9. package/dist/chunk-FE4PJSRB.js.map +1 -0
  10. package/dist/chunk-JXZMAU2C.js +559 -0
  11. package/dist/chunk-JXZMAU2C.js.map +1 -0
  12. package/dist/chunk-SVWLTRHD.js +2456 -0
  13. package/dist/chunk-SVWLTRHD.js.map +1 -0
  14. package/dist/chunk-Z532A7QC.js +78 -0
  15. package/dist/chunk-Z532A7QC.js.map +1 -0
  16. package/dist/file-stream.d.ts +43 -0
  17. package/dist/file-stream.js +9 -0
  18. package/dist/file-stream.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.js +67 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/interpreter.d.ts +33 -0
  23. package/dist/interpreter.js +8 -0
  24. package/dist/interpreter.js.map +1 -0
  25. package/dist/request-handler.d.ts +18 -0
  26. package/dist/request-handler.js +13 -0
  27. package/dist/request-handler.js.map +1 -0
  28. package/dist/sandbox-DWQVgVTY.d.ts +603 -0
  29. package/dist/sandbox.d.ts +4 -0
  30. package/dist/sandbox.js +13 -0
  31. package/dist/sandbox.js.map +1 -0
  32. package/dist/security.d.ts +31 -0
  33. package/dist/security.js +13 -0
  34. package/dist/security.js.map +1 -0
  35. package/dist/sse-parser.d.ts +28 -0
  36. package/dist/sse-parser.js +11 -0
  37. package/dist/sse-parser.js.map +1 -0
  38. package/dist/version.d.ts +8 -0
  39. package/dist/version.js +7 -0
  40. package/dist/version.js.map +1 -0
  41. package/package.json +13 -5
  42. package/src/clients/base-client.ts +280 -0
  43. package/src/clients/command-client.ts +115 -0
  44. package/src/clients/file-client.ts +295 -0
  45. package/src/clients/git-client.ts +92 -0
  46. package/src/clients/index.ts +64 -0
  47. package/src/{jupyter-client.ts → clients/interpreter-client.ts} +148 -168
  48. package/src/clients/port-client.ts +105 -0
  49. package/src/clients/process-client.ts +177 -0
  50. package/src/clients/sandbox-client.ts +41 -0
  51. package/src/clients/types.ts +84 -0
  52. package/src/clients/utility-client.ts +119 -0
  53. package/src/errors/adapter.ts +180 -0
  54. package/src/errors/classes.ts +469 -0
  55. package/src/errors/index.ts +105 -0
  56. package/src/file-stream.ts +164 -0
  57. package/src/index.ts +87 -50
  58. package/src/interpreter.ts +22 -13
  59. package/src/request-handler.ts +80 -44
  60. package/src/sandbox.ts +671 -446
  61. package/src/security.ts +14 -23
  62. package/src/sse-parser.ts +4 -8
  63. package/src/version.ts +6 -0
  64. package/startup.sh +3 -0
  65. package/tests/base-client.test.ts +328 -0
  66. package/tests/command-client.test.ts +407 -0
  67. package/tests/file-client.test.ts +719 -0
  68. package/tests/file-stream.test.ts +306 -0
  69. package/tests/get-sandbox.test.ts +110 -0
  70. package/tests/git-client.test.ts +328 -0
  71. package/tests/port-client.test.ts +301 -0
  72. package/tests/process-client.test.ts +658 -0
  73. package/tests/request-handler.test.ts +240 -0
  74. package/tests/sandbox.test.ts +554 -0
  75. package/tests/sse-parser.test.ts +290 -0
  76. package/tests/utility-client.test.ts +332 -0
  77. package/tests/version.test.ts +16 -0
  78. package/tests/wrangler.jsonc +35 -0
  79. package/tsconfig.json +9 -1
  80. package/vitest.config.ts +31 -0
  81. package/container_src/bun.lock +0 -122
  82. package/container_src/circuit-breaker.ts +0 -121
  83. package/container_src/handler/exec.ts +0 -340
  84. package/container_src/handler/file.ts +0 -1064
  85. package/container_src/handler/git.ts +0 -182
  86. package/container_src/handler/ports.ts +0 -314
  87. package/container_src/handler/process.ts +0 -640
  88. package/container_src/index.ts +0 -663
  89. package/container_src/jupyter-server.ts +0 -579
  90. package/container_src/jupyter-service.ts +0 -461
  91. package/container_src/jupyter_config.py +0 -48
  92. package/container_src/mime-processor.ts +0 -255
  93. package/container_src/package.json +0 -18
  94. package/container_src/startup.sh +0 -84
  95. package/container_src/types.ts +0 -117
  96. package/src/client.ts +0 -1024
  97. package/src/errors.ts +0 -218
  98. package/src/interpreter-types.ts +0 -383
  99. package/src/types.ts +0 -511
package/src/sandbox.ts CHANGED
@@ -1,77 +1,108 @@
1
+ import type { DurableObject } from 'cloudflare:workers';
1
2
  import { Container, getContainer } from "@cloudflare/containers";
2
- import { CodeInterpreter } from "./interpreter";
3
3
  import type {
4
4
  CodeContext,
5
5
  CreateContextOptions,
6
+ ExecEvent,
7
+ ExecOptions,
8
+ ExecResult,
6
9
  ExecutionResult,
10
+ ExecutionSession,
11
+ ISandbox,
12
+ Process,
13
+ ProcessOptions,
14
+ ProcessStatus,
7
15
  RunCodeOptions,
8
- } from "./interpreter-types";
9
- import { JupyterClient } from "./jupyter-client";
16
+ SandboxOptions,
17
+ SessionOptions,
18
+ StreamOptions
19
+ } from "@repo/shared";
20
+ import { createLogger, runWithLogger, TraceContext } from "@repo/shared";
21
+ import { type ExecuteResponse, SandboxClient } from "./clients";
22
+ import type { ErrorResponse } from './errors';
23
+ import { CustomDomainRequiredError, ErrorCode } from './errors';
24
+ import { CodeInterpreter } from "./interpreter";
10
25
  import { isLocalhostPattern } from "./request-handler";
11
26
  import {
12
- logSecurityEvent,
13
27
  SecurityError,
14
28
  sanitizeSandboxId,
15
- validatePort,
29
+ validatePort
16
30
  } from "./security";
17
31
  import { parseSSEStream } from "./sse-parser";
18
- import type {
19
- ExecEvent,
20
- ExecOptions,
21
- ExecResult,
22
- ExecuteResponse,
23
- ISandbox,
24
- Process,
25
- ProcessOptions,
26
- ProcessStatus,
27
- StreamOptions,
28
- } from "./types";
29
- import { ProcessNotFoundError, SandboxError } from "./types";
32
+ import { SDK_VERSION } from "./version";
30
33
 
31
- export function getSandbox(ns: DurableObjectNamespace<Sandbox>, id: string) {
34
+ export function getSandbox(
35
+ ns: DurableObjectNamespace<Sandbox>,
36
+ id: string,
37
+ options?: SandboxOptions
38
+ ) {
32
39
  const stub = getContainer(ns, id);
33
40
 
34
41
  // Store the name on first access
35
42
  stub.setSandboxName?.(id);
36
43
 
44
+ if (options?.baseUrl) {
45
+ stub.setBaseUrl(options.baseUrl);
46
+ }
47
+
48
+ if (options?.sleepAfter !== undefined) {
49
+ stub.setSleepAfter(options.sleepAfter);
50
+ }
51
+
37
52
  return stub;
38
53
  }
39
54
 
40
55
  export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
41
56
  defaultPort = 3000; // Default port for the container's Bun server
42
- sleepAfter = "20m"; // Keep container warm for 20 minutes to avoid cold starts
43
- client: JupyterClient;
44
- private sandboxName: string | null = null;
57
+ sleepAfter: string | number = "10m"; // Sleep the sandbox if no requests are made in this timeframe
58
+
59
+ client: SandboxClient;
45
60
  private codeInterpreter: CodeInterpreter;
61
+ private sandboxName: string | null = null;
62
+ private baseUrl: string | null = null;
63
+ private portTokens: Map<number, string> = new Map();
64
+ private defaultSession: string | null = null;
65
+ envVars: Record<string, string> = {};
66
+ private logger: ReturnType<typeof createLogger>;
46
67
 
47
- constructor(ctx: DurableObjectState, env: Env) {
68
+ constructor(ctx: DurableObject['ctx'], env: Env) {
48
69
  super(ctx, env);
49
- this.client = new JupyterClient({
50
- onCommandComplete: (success, exitCode, _stdout, _stderr, command) => {
51
- console.log(
52
- `[Container] Command completed: ${command}, Success: ${success}, Exit code: ${exitCode}`
53
- );
54
- },
55
- onCommandStart: (command) => {
56
- console.log(`[Container] Command started: ${command}`);
57
- },
58
- onError: (error, _command) => {
59
- console.error(`[Container] Command error: ${error}`);
60
- },
61
- onOutput: (stream, data, _command) => {
62
- console.log(`[Container] [${stream}] ${data}`);
63
- },
70
+
71
+ const envObj = env as any;
72
+ // Set sandbox environment variables from env object
73
+ const sandboxEnvKeys = ['SANDBOX_LOG_LEVEL', 'SANDBOX_LOG_FORMAT'] as const;
74
+ sandboxEnvKeys.forEach(key => {
75
+ if (envObj?.[key]) {
76
+ this.envVars[key] = envObj[key];
77
+ }
78
+ });
79
+
80
+ this.logger = createLogger({
81
+ component: 'sandbox-do',
82
+ sandboxId: this.ctx.id.toString()
83
+ });
84
+
85
+ this.client = new SandboxClient({
86
+ logger: this.logger,
64
87
  port: 3000, // Control plane port
65
88
  stub: this,
66
89
  });
67
90
 
68
- // Initialize code interpreter
91
+ // Initialize code interpreter - pass 'this' after client is ready
92
+ // The CodeInterpreter extracts client.interpreter from the sandbox
69
93
  this.codeInterpreter = new CodeInterpreter(this);
70
94
 
71
- // Load the sandbox name from storage on initialization
95
+ // Load the sandbox name, port tokens, and default session from storage on initialization
72
96
  this.ctx.blockConcurrencyWhile(async () => {
73
- this.sandboxName =
74
- (await this.ctx.storage.get<string>("sandboxName")) || null;
97
+ this.sandboxName = await this.ctx.storage.get<string>('sandboxName') || null;
98
+ this.defaultSession = await this.ctx.storage.get<string>('defaultSession') || null;
99
+ const storedTokens = await this.ctx.storage.get<Record<string, string>>('portTokens') || {};
100
+
101
+ // Convert stored tokens back to Map
102
+ this.portTokens = new Map();
103
+ for (const [portStr, token] of Object.entries(storedTokens)) {
104
+ this.portTokens.set(parseInt(portStr, 10), token);
105
+ }
75
106
  });
76
107
  }
77
108
 
@@ -79,56 +110,157 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
79
110
  async setSandboxName(name: string): Promise<void> {
80
111
  if (!this.sandboxName) {
81
112
  this.sandboxName = name;
82
- await this.ctx.storage.put("sandboxName", name);
83
- console.log(`[Sandbox] Stored sandbox name via RPC: ${name}`);
113
+ await this.ctx.storage.put('sandboxName', name);
84
114
  }
85
115
  }
86
116
 
117
+ // RPC method to set the base URL
118
+ async setBaseUrl(baseUrl: string): Promise<void> {
119
+ if (!this.baseUrl) {
120
+ this.baseUrl = baseUrl;
121
+ await this.ctx.storage.put('baseUrl', baseUrl);
122
+ } else {
123
+ if(this.baseUrl !== baseUrl) {
124
+ throw new Error('Base URL already set and different from one previously provided');
125
+ }
126
+ }
127
+ }
128
+
129
+ // RPC method to set the sleep timeout
130
+ async setSleepAfter(sleepAfter: string | number): Promise<void> {
131
+ this.sleepAfter = sleepAfter;
132
+ }
133
+
87
134
  // RPC method to set environment variables
88
135
  async setEnvVars(envVars: Record<string, string>): Promise<void> {
136
+ // Update local state for new sessions
89
137
  this.envVars = { ...this.envVars, ...envVars };
90
- console.log(`[Sandbox] Updated environment variables`);
138
+
139
+ // If default session already exists, update it directly
140
+ if (this.defaultSession) {
141
+ // Set environment variables by executing export commands in the existing session
142
+ for (const [key, value] of Object.entries(envVars)) {
143
+ const escapedValue = value.replace(/'/g, "'\\''");
144
+ const exportCommand = `export ${key}='${escapedValue}'`;
145
+
146
+ const result = await this.client.commands.execute(exportCommand, this.defaultSession);
147
+
148
+ if (result.exitCode !== 0) {
149
+ throw new Error(`Failed to set ${key}: ${result.stderr || 'Unknown error'}`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Cleanup and destroy the sandbox container
157
+ */
158
+ override async destroy(): Promise<void> {
159
+ this.logger.info('Destroying sandbox container');
160
+ await super.destroy();
91
161
  }
92
162
 
93
163
  override onStart() {
94
- console.log("Sandbox successfully started");
164
+ this.logger.debug('Sandbox started');
165
+
166
+ // Check version compatibility asynchronously (don't block startup)
167
+ this.checkVersionCompatibility().catch(error => {
168
+ this.logger.error('Version compatibility check failed', error instanceof Error ? error : new Error(String(error)));
169
+ });
95
170
  }
96
171
 
97
- override onStop() {
98
- console.log("Sandbox successfully shut down");
99
- if (this.client) {
100
- this.client.clearSession();
172
+ /**
173
+ * Check if the container version matches the SDK version
174
+ * Logs a warning if there's a mismatch
175
+ */
176
+ private async checkVersionCompatibility(): Promise<void> {
177
+ try {
178
+ // Get the SDK version (imported from version.ts)
179
+ const sdkVersion = SDK_VERSION;
180
+
181
+ // Get container version
182
+ const containerVersion = await this.client.utils.getVersion();
183
+
184
+ // If container version is unknown, it's likely an old container without the endpoint
185
+ if (containerVersion === 'unknown') {
186
+ this.logger.warn(
187
+ 'Container version check: Container version could not be determined. ' +
188
+ 'This may indicate an outdated container image. ' +
189
+ 'Please update your container to match SDK version ' + sdkVersion
190
+ );
191
+ return;
192
+ }
193
+
194
+ // Check if versions match
195
+ if (containerVersion !== sdkVersion) {
196
+ const message =
197
+ `Version mismatch detected! SDK version (${sdkVersion}) does not match ` +
198
+ `container version (${containerVersion}). This may cause compatibility issues. ` +
199
+ `Please update your container image to version ${sdkVersion}`;
200
+
201
+ // Log warning - we can't reliably detect dev vs prod environment in Durable Objects
202
+ // so we always use warning level as requested by the user
203
+ this.logger.warn(message);
204
+ } else {
205
+ this.logger.debug('Version check passed', { sdkVersion, containerVersion });
206
+ }
207
+ } catch (error) {
208
+ // Don't fail the sandbox initialization if version check fails
209
+ this.logger.debug('Version compatibility check encountered an error', {
210
+ error: error instanceof Error ? error.message : String(error)
211
+ });
101
212
  }
102
213
  }
103
214
 
215
+ override onStop() {
216
+ this.logger.debug('Sandbox stopped');
217
+ }
218
+
104
219
  override onError(error: unknown) {
105
- console.log("Sandbox error:", error);
220
+ this.logger.error('Sandbox error', error instanceof Error ? error : new Error(String(error)));
106
221
  }
107
222
 
108
223
  // Override fetch to route internal container requests to appropriate ports
109
224
  override async fetch(request: Request): Promise<Response> {
110
- const url = new URL(request.url);
225
+ // Extract or generate trace ID from request
226
+ const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate();
111
227
 
112
- // Capture and store the sandbox name from the header if present
113
- if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
114
- const name = request.headers.get("X-Sandbox-Name")!;
115
- this.sandboxName = name;
116
- await this.ctx.storage.put("sandboxName", name);
117
- console.log(`[Sandbox] Stored sandbox name: ${this.sandboxName}`);
118
- }
228
+ // Create request-specific logger with trace ID
229
+ const requestLogger = this.logger.child({ traceId, operation: 'fetch' });
230
+
231
+ return await runWithLogger(requestLogger, async () => {
232
+ const url = new URL(request.url);
119
233
 
120
- // Determine which port to route to
121
- const port = this.determinePort(url);
234
+ // Capture and store the sandbox name from the header if present
235
+ if (!this.sandboxName && request.headers.has('X-Sandbox-Name')) {
236
+ const name = request.headers.get('X-Sandbox-Name')!;
237
+ this.sandboxName = name;
238
+ await this.ctx.storage.put('sandboxName', name);
239
+ }
240
+
241
+ // Detect WebSocket upgrade request
242
+ const upgradeHeader = request.headers.get('Upgrade');
243
+ const isWebSocket = upgradeHeader?.toLowerCase() === 'websocket';
244
+
245
+ if (isWebSocket) {
246
+ // WebSocket path: Let parent Container class handle WebSocket proxying
247
+ // This bypasses containerFetch() which uses JSRPC and cannot handle WebSocket upgrades
248
+ return await super.fetch(request);
249
+ }
122
250
 
123
- // Route to the appropriate port
124
- return await this.containerFetch(request, port);
251
+ // Non-WebSocket: Use existing port determination and HTTP routing logic
252
+ const port = this.determinePort(url);
253
+
254
+ // Route to the appropriate port
255
+ return await this.containerFetch(request, port);
256
+ });
125
257
  }
126
258
 
127
259
  private determinePort(url: URL): number {
128
260
  // Extract port from proxy requests (e.g., /proxy/8080/*)
129
261
  const proxyMatch = url.pathname.match(/^\/proxy\/(\d+)/);
130
262
  if (proxyMatch) {
131
- return parseInt(proxyMatch[1]);
263
+ return parseInt(proxyMatch[1], 10);
132
264
  }
133
265
 
134
266
  // All other requests go to control plane on port 3000
@@ -136,9 +268,62 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
136
268
  return 3000;
137
269
  }
138
270
 
271
+ /**
272
+ * Ensure default session exists - lazy initialization
273
+ * This is called automatically by all public methods that need a session
274
+ *
275
+ * The session is persisted to Durable Object storage to survive hot reloads
276
+ * during development. If a session already exists in the container after reload,
277
+ * we reuse it instead of trying to create a new one.
278
+ */
279
+ private async ensureDefaultSession(): Promise<string> {
280
+ if (!this.defaultSession) {
281
+ const sessionId = `sandbox-${this.sandboxName || 'default'}`;
282
+
283
+ try {
284
+ // Try to create session in container
285
+ await this.client.utils.createSession({
286
+ id: sessionId,
287
+ env: this.envVars || {},
288
+ cwd: '/workspace',
289
+ });
290
+
291
+ this.defaultSession = sessionId;
292
+ // Persist to storage so it survives hot reloads
293
+ await this.ctx.storage.put('defaultSession', sessionId);
294
+ this.logger.debug('Default session initialized', { sessionId });
295
+ } catch (error: any) {
296
+ // If session already exists (e.g., after hot reload), reuse it
297
+ if (error?.message?.includes('already exists')) {
298
+ this.logger.debug('Reusing existing session after reload', { sessionId });
299
+ this.defaultSession = sessionId;
300
+ // Persist to storage in case it wasn't saved before
301
+ await this.ctx.storage.put('defaultSession', sessionId);
302
+ } else {
303
+ // Re-throw other errors
304
+ throw error;
305
+ }
306
+ }
307
+ }
308
+ return this.defaultSession;
309
+ }
310
+
139
311
  // Enhanced exec method - always returns ExecResult with optional streaming
140
312
  // This replaces the old exec method to match ISandbox interface
141
313
  async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
314
+ const session = await this.ensureDefaultSession();
315
+ return this.execWithSession(command, session, options);
316
+ }
317
+
318
+ /**
319
+ * Internal session-aware exec implementation
320
+ * Used by both public exec() and session wrappers
321
+ */
322
+ private async execWithSession(
323
+ command: string,
324
+ sessionId: string,
325
+ options?: ExecOptions
326
+ ): Promise<ExecResult> {
142
327
  const startTime = Date.now();
143
328
  const timestamp = new Date().toISOString();
144
329
 
@@ -148,33 +333,20 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
148
333
  try {
149
334
  // Handle cancellation
150
335
  if (options?.signal?.aborted) {
151
- throw new Error("Operation was aborted");
336
+ throw new Error('Operation was aborted');
152
337
  }
153
338
 
154
339
  let result: ExecResult;
155
340
 
156
341
  if (options?.stream && options?.onOutput) {
157
342
  // Streaming with callbacks - we need to collect the final result
158
- result = await this.executeWithStreaming(
159
- command,
160
- options,
161
- startTime,
162
- timestamp
163
- );
343
+ result = await this.executeWithStreaming(command, sessionId, options, startTime, timestamp);
164
344
  } else {
165
- // Regular execution
166
- const response = await this.client.execute(command, {
167
- sessionId: options?.sessionId,
168
- cwd: options?.cwd,
169
- env: options?.env,
170
- });
345
+ // Regular execution with session
346
+ const response = await this.client.commands.execute(command, sessionId);
171
347
 
172
348
  const duration = Date.now() - startTime;
173
- result = this.mapExecuteResponseToExecResult(
174
- response,
175
- duration,
176
- options?.sessionId
177
- );
349
+ result = this.mapExecuteResponseToExecResult(response, duration, sessionId);
178
350
  }
179
351
 
180
352
  // Call completion callback if provided
@@ -197,32 +369,30 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
197
369
 
198
370
  private async executeWithStreaming(
199
371
  command: string,
372
+ sessionId: string,
200
373
  options: ExecOptions,
201
374
  startTime: number,
202
375
  timestamp: string
203
376
  ): Promise<ExecResult> {
204
- let stdout = "";
205
- let stderr = "";
377
+ let stdout = '';
378
+ let stderr = '';
206
379
 
207
380
  try {
208
- const stream = await this.client.executeCommandStream(
209
- command,
210
- options.sessionId
211
- );
381
+ const stream = await this.client.commands.executeStream(command, sessionId);
212
382
 
213
383
  for await (const event of parseSSEStream<ExecEvent>(stream)) {
214
384
  // Check for cancellation
215
385
  if (options.signal?.aborted) {
216
- throw new Error("Operation was aborted");
386
+ throw new Error('Operation was aborted');
217
387
  }
218
388
 
219
389
  switch (event.type) {
220
- case "stdout":
221
- case "stderr":
390
+ case 'stdout':
391
+ case 'stderr':
222
392
  if (event.data) {
223
393
  // Update accumulated output
224
- if (event.type === "stdout") stdout += event.data;
225
- if (event.type === "stderr") stderr += event.data;
394
+ if (event.type === 'stdout') stdout += event.data;
395
+ if (event.type === 'stderr') stderr += event.data;
226
396
 
227
397
  // Call user's callback
228
398
  if (options.onOutput) {
@@ -231,33 +401,32 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
231
401
  }
232
402
  break;
233
403
 
234
- case "complete": {
404
+ case 'complete': {
235
405
  // Use result from complete event if available
236
406
  const duration = Date.now() - startTime;
237
- return (
238
- event.result || {
239
- success: event.exitCode === 0,
240
- exitCode: event.exitCode || 0,
241
- stdout,
242
- stderr,
243
- command,
244
- duration,
245
- timestamp,
246
- sessionId: options.sessionId,
247
- }
248
- );
407
+ return {
408
+ success: (event.exitCode ?? 0) === 0,
409
+ exitCode: event.exitCode ?? 0,
410
+ stdout,
411
+ stderr,
412
+ command,
413
+ duration,
414
+ timestamp,
415
+ sessionId
416
+ };
249
417
  }
250
418
 
251
- case "error":
252
- throw new Error(event.error || "Command execution failed");
419
+ case 'error':
420
+ throw new Error(event.data || 'Command execution failed');
253
421
  }
254
422
  }
255
423
 
256
424
  // If we get here without a complete event, something went wrong
257
- throw new Error("Stream ended without completion event");
425
+ throw new Error('Stream ended without completion event');
426
+
258
427
  } catch (error) {
259
428
  if (options.signal?.aborted) {
260
- throw new Error("Operation was aborted");
429
+ throw new Error('Operation was aborted');
261
430
  }
262
431
  throw error;
263
432
  }
@@ -276,63 +445,72 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
276
445
  command: response.command,
277
446
  duration,
278
447
  timestamp: response.timestamp,
448
+ sessionId
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Create a Process domain object from HTTP client DTO
454
+ * Centralizes process object creation with bound methods
455
+ * This eliminates duplication across startProcess, listProcesses, getProcess, and session wrappers
456
+ */
457
+ private createProcessFromDTO(
458
+ data: {
459
+ id: string;
460
+ pid?: number;
461
+ command: string;
462
+ status: ProcessStatus;
463
+ startTime: string | Date;
464
+ endTime?: string | Date;
465
+ exitCode?: number;
466
+ },
467
+ sessionId: string
468
+ ): Process {
469
+ return {
470
+ id: data.id,
471
+ pid: data.pid,
472
+ command: data.command,
473
+ status: data.status,
474
+ startTime: typeof data.startTime === 'string' ? new Date(data.startTime) : data.startTime,
475
+ endTime: data.endTime ? (typeof data.endTime === 'string' ? new Date(data.endTime) : data.endTime) : undefined,
476
+ exitCode: data.exitCode,
279
477
  sessionId,
478
+
479
+ kill: async (signal?: string) => {
480
+ await this.killProcess(data.id, signal);
481
+ },
482
+
483
+ getStatus: async () => {
484
+ const current = await this.getProcess(data.id);
485
+ return current?.status || 'error';
486
+ },
487
+
488
+ getLogs: async () => {
489
+ const logs = await this.getProcessLogs(data.id);
490
+ return { stdout: logs.stdout, stderr: logs.stderr };
491
+ }
280
492
  };
281
493
  }
282
494
 
495
+
283
496
  // Background process management
284
- async startProcess(
285
- command: string,
286
- options?: ProcessOptions
287
- ): Promise<Process> {
497
+ async startProcess(command: string, options?: ProcessOptions, sessionId?: string): Promise<Process> {
288
498
  // Use the new HttpClient method to start the process
289
499
  try {
290
- const response = await this.client.startProcess(command, {
291
- processId: options?.processId,
292
- sessionId: options?.sessionId,
293
- timeout: options?.timeout,
294
- env: options?.env,
295
- cwd: options?.cwd,
296
- encoding: options?.encoding,
297
- autoCleanup: options?.autoCleanup,
500
+ const session = sessionId ?? await this.ensureDefaultSession();
501
+ const response = await this.client.processes.startProcess(command, session, {
502
+ processId: options?.processId
298
503
  });
299
504
 
300
- const process = response.process;
301
- const processObj: Process = {
302
- id: process.id,
303
- pid: process.pid,
304
- command: process.command,
305
- status: process.status as ProcessStatus,
306
- startTime: new Date(process.startTime),
505
+ const processObj = this.createProcessFromDTO({
506
+ id: response.processId,
507
+ pid: response.pid,
508
+ command: response.command,
509
+ status: 'running' as ProcessStatus,
510
+ startTime: new Date(),
307
511
  endTime: undefined,
308
- exitCode: undefined,
309
- sessionId: process.sessionId,
310
-
311
- async kill(): Promise<void> {
312
- throw new Error("Method will be replaced");
313
- },
314
- async getStatus(): Promise<ProcessStatus> {
315
- throw new Error("Method will be replaced");
316
- },
317
- async getLogs(): Promise<{ stdout: string; stderr: string }> {
318
- throw new Error("Method will be replaced");
319
- },
320
- };
321
-
322
- // Bind context properly
323
- processObj.kill = async (signal?: string) => {
324
- await this.killProcess(process.id, signal);
325
- };
326
-
327
- processObj.getStatus = async () => {
328
- const current = await this.getProcess(process.id);
329
- return current?.status || "error";
330
- };
331
-
332
- processObj.getLogs = async () => {
333
- const logs = await this.getProcessLogs(process.id);
334
- return { stdout: logs.stdout, stderr: logs.stderr };
335
- };
512
+ exitCode: undefined
513
+ }, session);
336
514
 
337
515
  // Call onStart callback if provided
338
516
  if (options?.onStart) {
@@ -340,6 +518,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
340
518
  }
341
519
 
342
520
  return processObj;
521
+
343
522
  } catch (error) {
344
523
  if (options?.onError && error instanceof Error) {
345
524
  options.onError(error);
@@ -349,216 +528,217 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
349
528
  }
350
529
  }
351
530
 
352
- async listProcesses(): Promise<Process[]> {
353
- const response = await this.client.listProcesses();
354
-
355
- return response.processes.map((processData) => ({
356
- id: processData.id,
357
- pid: processData.pid,
358
- command: processData.command,
359
- status: processData.status,
360
- startTime: new Date(processData.startTime),
361
- endTime: processData.endTime ? new Date(processData.endTime) : undefined,
362
- exitCode: processData.exitCode,
363
- sessionId: processData.sessionId,
364
-
365
- kill: async (signal?: string) => {
366
- await this.killProcess(processData.id, signal);
367
- },
368
-
369
- getStatus: async () => {
370
- const current = await this.getProcess(processData.id);
371
- return current?.status || "error";
372
- },
373
-
374
- getLogs: async () => {
375
- const logs = await this.getProcessLogs(processData.id);
376
- return { stdout: logs.stdout, stderr: logs.stderr };
377
- },
378
- }));
531
+ async listProcesses(sessionId?: string): Promise<Process[]> {
532
+ const session = sessionId ?? await this.ensureDefaultSession();
533
+ const response = await this.client.processes.listProcesses();
534
+
535
+ return response.processes.map(processData =>
536
+ this.createProcessFromDTO({
537
+ id: processData.id,
538
+ pid: processData.pid,
539
+ command: processData.command,
540
+ status: processData.status,
541
+ startTime: processData.startTime,
542
+ endTime: processData.endTime,
543
+ exitCode: processData.exitCode
544
+ }, session)
545
+ );
379
546
  }
380
547
 
381
- async getProcess(id: string): Promise<Process | null> {
382
- const response = await this.client.getProcess(id);
548
+ async getProcess(id: string, sessionId?: string): Promise<Process | null> {
549
+ const session = sessionId ?? await this.ensureDefaultSession();
550
+ const response = await this.client.processes.getProcess(id);
383
551
  if (!response.process) {
384
552
  return null;
385
553
  }
386
554
 
387
555
  const processData = response.process;
388
- return {
556
+ return this.createProcessFromDTO({
389
557
  id: processData.id,
390
558
  pid: processData.pid,
391
559
  command: processData.command,
392
560
  status: processData.status,
393
- startTime: new Date(processData.startTime),
394
- endTime: processData.endTime ? new Date(processData.endTime) : undefined,
395
- exitCode: processData.exitCode,
396
- sessionId: processData.sessionId,
397
-
398
- kill: async (signal?: string) => {
399
- await this.killProcess(processData.id, signal);
400
- },
401
-
402
- getStatus: async () => {
403
- const current = await this.getProcess(processData.id);
404
- return current?.status || "error";
405
- },
406
-
407
- getLogs: async () => {
408
- const logs = await this.getProcessLogs(processData.id);
409
- return { stdout: logs.stdout, stderr: logs.stderr };
410
- },
411
- };
561
+ startTime: processData.startTime,
562
+ endTime: processData.endTime,
563
+ exitCode: processData.exitCode
564
+ }, session);
412
565
  }
413
566
 
414
- async killProcess(id: string, _signal?: string): Promise<void> {
415
- try {
416
- // Note: signal parameter is not currently supported by the HttpClient implementation
417
- await this.client.killProcess(id);
418
- } catch (error) {
419
- if (
420
- error instanceof Error &&
421
- error.message.includes("Process not found")
422
- ) {
423
- throw new ProcessNotFoundError(id);
424
- }
425
- throw new SandboxError(
426
- `Failed to kill process ${id}: ${
427
- error instanceof Error ? error.message : "Unknown error"
428
- }`,
429
- "KILL_PROCESS_FAILED"
430
- );
431
- }
567
+ async killProcess(id: string, signal?: string, sessionId?: string): Promise<void> {
568
+ // Note: signal parameter is not currently supported by the HttpClient implementation
569
+ // The HTTP client already throws properly typed errors, so we just let them propagate
570
+ await this.client.processes.killProcess(id);
432
571
  }
433
572
 
434
- async killAllProcesses(): Promise<number> {
435
- const response = await this.client.killAllProcesses();
436
- return response.killedCount;
573
+ async killAllProcesses(sessionId?: string): Promise<number> {
574
+ const response = await this.client.processes.killAllProcesses();
575
+ return response.cleanedCount;
437
576
  }
438
577
 
439
- async cleanupCompletedProcesses(): Promise<number> {
578
+ async cleanupCompletedProcesses(sessionId?: string): Promise<number> {
440
579
  // For now, this would need to be implemented as a container endpoint
441
580
  // as we no longer maintain local process storage
442
581
  // We'll return 0 as a placeholder until the container endpoint is added
443
582
  return 0;
444
583
  }
445
584
 
446
- async getProcessLogs(
447
- id: string
448
- ): Promise<{ stdout: string; stderr: string }> {
449
- try {
450
- const response = await this.client.getProcessLogs(id);
451
- return {
452
- stdout: response.stdout,
453
- stderr: response.stderr,
454
- };
455
- } catch (error) {
456
- if (
457
- error instanceof Error &&
458
- error.message.includes("Process not found")
459
- ) {
460
- throw new ProcessNotFoundError(id);
461
- }
462
- throw error;
463
- }
585
+ async getProcessLogs(id: string, sessionId?: string): Promise<{ stdout: string; stderr: string; processId: string }> {
586
+ // The HTTP client already throws properly typed errors, so we just let them propagate
587
+ const response = await this.client.processes.getProcessLogs(id);
588
+ return {
589
+ stdout: response.stdout,
590
+ stderr: response.stderr,
591
+ processId: response.processId
592
+ };
464
593
  }
465
594
 
595
+
466
596
  // Streaming methods - return ReadableStream for RPC compatibility
467
- async execStream(
468
- command: string,
469
- options?: StreamOptions
470
- ): Promise<ReadableStream<Uint8Array>> {
597
+ async execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>> {
471
598
  // Check for cancellation
472
599
  if (options?.signal?.aborted) {
473
- throw new Error("Operation was aborted");
600
+ throw new Error('Operation was aborted');
474
601
  }
475
602
 
476
- // Get the stream from HttpClient (need to add this method)
477
- const stream = await this.client.executeCommandStream(
478
- command,
479
- options?.sessionId
480
- );
481
-
482
- // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
483
- return stream;
603
+ const session = await this.ensureDefaultSession();
604
+ // Get the stream from CommandClient
605
+ return this.client.commands.executeStream(command, session);
484
606
  }
485
607
 
486
- async streamProcessLogs(
487
- processId: string,
488
- options?: { signal?: AbortSignal }
489
- ): Promise<ReadableStream<Uint8Array>> {
608
+ /**
609
+ * Internal session-aware execStream implementation
610
+ */
611
+ private async execStreamWithSession(command: string, sessionId: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>> {
490
612
  // Check for cancellation
491
613
  if (options?.signal?.aborted) {
492
- throw new Error("Operation was aborted");
614
+ throw new Error('Operation was aborted');
493
615
  }
494
616
 
495
- // Get the stream from HttpClient
496
- const stream = await this.client.streamProcessLogs(processId);
617
+ return this.client.commands.executeStream(command, sessionId);
618
+ }
619
+
620
+ async streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> {
621
+ // Check for cancellation
622
+ if (options?.signal?.aborted) {
623
+ throw new Error('Operation was aborted');
624
+ }
497
625
 
498
- // Return the ReadableStream directly - can be converted to AsyncIterable by consumers
499
- return stream;
626
+ return this.client.processes.streamProcessLogs(processId);
500
627
  }
501
628
 
502
629
  async gitCheckout(
503
630
  repoUrl: string,
504
- options: { branch?: string; targetDir?: string }
631
+ options: { branch?: string; targetDir?: string; sessionId?: string }
505
632
  ) {
506
- return this.client.gitCheckout(repoUrl, options.branch, options.targetDir);
633
+ const session = options.sessionId ?? await this.ensureDefaultSession();
634
+ return this.client.git.checkout(repoUrl, session, {
635
+ branch: options.branch,
636
+ targetDir: options.targetDir
637
+ });
507
638
  }
508
639
 
509
- async mkdir(path: string, options: { recursive?: boolean } = {}) {
510
- return this.client.mkdir(path, options.recursive);
640
+ async mkdir(
641
+ path: string,
642
+ options: { recursive?: boolean; sessionId?: string } = {}
643
+ ) {
644
+ const session = options.sessionId ?? await this.ensureDefaultSession();
645
+ return this.client.files.mkdir(path, session, { recursive: options.recursive });
511
646
  }
512
647
 
513
648
  async writeFile(
514
649
  path: string,
515
650
  content: string,
516
- options: { encoding?: string } = {}
651
+ options: { encoding?: string; sessionId?: string } = {}
517
652
  ) {
518
- return this.client.writeFile(path, content, options.encoding);
653
+ const session = options.sessionId ?? await this.ensureDefaultSession();
654
+ return this.client.files.writeFile(path, content, session, { encoding: options.encoding });
655
+ }
656
+
657
+ async deleteFile(path: string, sessionId?: string) {
658
+ const session = sessionId ?? await this.ensureDefaultSession();
659
+ return this.client.files.deleteFile(path, session);
519
660
  }
520
661
 
521
- async deleteFile(path: string) {
522
- return this.client.deleteFile(path);
662
+ async renameFile(
663
+ oldPath: string,
664
+ newPath: string,
665
+ sessionId?: string
666
+ ) {
667
+ const session = sessionId ?? await this.ensureDefaultSession();
668
+ return this.client.files.renameFile(oldPath, newPath, session);
523
669
  }
524
670
 
525
- async renameFile(oldPath: string, newPath: string) {
526
- return this.client.renameFile(oldPath, newPath);
671
+ async moveFile(
672
+ sourcePath: string,
673
+ destinationPath: string,
674
+ sessionId?: string
675
+ ) {
676
+ const session = sessionId ?? await this.ensureDefaultSession();
677
+ return this.client.files.moveFile(sourcePath, destinationPath, session);
527
678
  }
528
679
 
529
- async moveFile(sourcePath: string, destinationPath: string) {
530
- return this.client.moveFile(sourcePath, destinationPath);
680
+ async readFile(
681
+ path: string,
682
+ options: { encoding?: string; sessionId?: string } = {}
683
+ ) {
684
+ const session = options.sessionId ?? await this.ensureDefaultSession();
685
+ return this.client.files.readFile(path, session, { encoding: options.encoding });
531
686
  }
532
687
 
533
- async readFile(path: string, options: { encoding?: string } = {}) {
534
- return this.client.readFile(path, options.encoding);
688
+ /**
689
+ * Stream a file from the sandbox using Server-Sent Events
690
+ * Returns a ReadableStream that can be consumed with streamFile() or collectFile() utilities
691
+ * @param path - Path to the file to stream
692
+ * @param options - Optional session ID
693
+ */
694
+ async readFileStream(
695
+ path: string,
696
+ options: { sessionId?: string } = {}
697
+ ): Promise<ReadableStream<Uint8Array>> {
698
+ const session = options.sessionId ?? await this.ensureDefaultSession();
699
+ return this.client.files.readFileStream(path, session);
535
700
  }
536
701
 
537
702
  async listFiles(
538
703
  path: string,
539
- options: {
540
- recursive?: boolean;
541
- includeHidden?: boolean;
542
- } = {}
704
+ options?: { recursive?: boolean; includeHidden?: boolean }
543
705
  ) {
544
- return this.client.listFiles(path, options);
706
+ const session = await this.ensureDefaultSession();
707
+ return this.client.files.listFiles(path, session, options);
708
+ }
709
+
710
+ async exists(path: string, sessionId?: string) {
711
+ const session = sessionId ?? await this.ensureDefaultSession();
712
+ return this.client.files.exists(path, session);
545
713
  }
546
714
 
547
715
  async exposePort(port: number, options: { name?: string; hostname: string }) {
548
- await this.client.exposePort(port, options?.name);
716
+ // Check if hostname is workers.dev domain (doesn't support wildcard subdomains)
717
+ if (options.hostname.endsWith('.workers.dev')) {
718
+ const errorResponse: ErrorResponse = {
719
+ code: ErrorCode.CUSTOM_DOMAIN_REQUIRED,
720
+ message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`,
721
+ context: { originalError: options.hostname },
722
+ httpStatus: 400,
723
+ timestamp: new Date().toISOString()
724
+ };
725
+ throw new CustomDomainRequiredError(errorResponse);
726
+ }
727
+
728
+ const sessionId = await this.ensureDefaultSession();
729
+ await this.client.ports.exposePort(port, sessionId, options?.name);
549
730
 
550
731
  // We need the sandbox name to construct preview URLs
551
732
  if (!this.sandboxName) {
552
- throw new Error(
553
- "Sandbox name not available. Ensure sandbox is accessed through getSandbox()"
554
- );
733
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
555
734
  }
556
735
 
557
- const url = this.constructPreviewUrl(
558
- port,
559
- this.sandboxName,
560
- options.hostname
561
- );
736
+ // Generate and store token for this port
737
+ const token = this.generatePortToken();
738
+ this.portTokens.set(port, token);
739
+ await this.persistPortTokens();
740
+
741
+ const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
562
742
 
563
743
  return {
564
744
  url,
@@ -569,129 +749,119 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
569
749
 
570
750
  async unexposePort(port: number) {
571
751
  if (!validatePort(port)) {
572
- logSecurityEvent(
573
- "INVALID_PORT_UNEXPOSE",
574
- {
575
- port,
576
- },
577
- "high"
578
- );
579
- throw new SecurityError(
580
- `Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`
581
- );
752
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
582
753
  }
583
754
 
584
- await this.client.unexposePort(port);
755
+ const sessionId = await this.ensureDefaultSession();
756
+ await this.client.ports.unexposePort(port, sessionId);
585
757
 
586
- logSecurityEvent(
587
- "PORT_UNEXPOSED",
588
- {
589
- port,
590
- },
591
- "low"
592
- );
758
+ // Clean up token for this port
759
+ if (this.portTokens.has(port)) {
760
+ this.portTokens.delete(port);
761
+ await this.persistPortTokens();
762
+ }
593
763
  }
594
764
 
595
765
  async getExposedPorts(hostname: string) {
596
- const response = await this.client.getExposedPorts();
766
+ const sessionId = await this.ensureDefaultSession();
767
+ const response = await this.client.ports.getExposedPorts(sessionId);
597
768
 
598
769
  // We need the sandbox name to construct preview URLs
599
770
  if (!this.sandboxName) {
600
- throw new Error(
601
- "Sandbox name not available. Ensure sandbox is accessed through getSandbox()"
602
- );
771
+ throw new Error('Sandbox name not available. Ensure sandbox is accessed through getSandbox()');
603
772
  }
604
773
 
605
- return response.ports.map((port) => ({
606
- url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname),
607
- port: port.port,
608
- name: port.name,
609
- exposedAt: port.exposedAt,
610
- }));
774
+ return response.ports.map(port => {
775
+ // Get token for this port - must exist for all exposed ports
776
+ const token = this.portTokens.get(port.port);
777
+ if (!token) {
778
+ throw new Error(`Port ${port.port} is exposed but has no token. This should not happen.`);
779
+ }
780
+
781
+ return {
782
+ url: this.constructPreviewUrl(port.port, this.sandboxName!, hostname, token),
783
+ port: port.port,
784
+ status: port.status,
785
+ };
786
+ });
611
787
  }
612
788
 
613
- private constructPreviewUrl(
614
- port: number,
615
- sandboxId: string,
616
- hostname: string
617
- ): string {
618
- if (!validatePort(port)) {
619
- logSecurityEvent(
620
- "INVALID_PORT_REJECTED",
621
- {
622
- port,
623
- sandboxId,
624
- hostname,
625
- },
626
- "high"
627
- );
628
- throw new SecurityError(
629
- `Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`
630
- );
631
- }
632
789
 
633
- let sanitizedSandboxId: string;
790
+ async isPortExposed(port: number): Promise<boolean> {
634
791
  try {
635
- sanitizedSandboxId = sanitizeSandboxId(sandboxId);
792
+ const sessionId = await this.ensureDefaultSession();
793
+ const response = await this.client.ports.getExposedPorts(sessionId);
794
+ return response.ports.some(exposedPort => exposedPort.port === port);
636
795
  } catch (error) {
637
- logSecurityEvent(
638
- "INVALID_SANDBOX_ID_REJECTED",
639
- {
640
- sandboxId,
641
- port,
642
- hostname,
643
- error: error instanceof Error ? error.message : "Unknown error",
644
- },
645
- "high"
646
- );
647
- throw error;
796
+ this.logger.error('Error checking if port is exposed', error instanceof Error ? error : new Error(String(error)), { port });
797
+ return false;
798
+ }
799
+ }
800
+
801
+ async validatePortToken(port: number, token: string): Promise<boolean> {
802
+ // First check if port is exposed
803
+ const isExposed = await this.isPortExposed(port);
804
+ if (!isExposed) {
805
+ return false;
806
+ }
807
+
808
+ // Get stored token for this port - must exist for all exposed ports
809
+ const storedToken = this.portTokens.get(port);
810
+ if (!storedToken) {
811
+ // This should not happen - all exposed ports must have tokens
812
+ this.logger.error('Port is exposed but has no token - bug detected', undefined, { port });
813
+ return false;
814
+ }
815
+
816
+ // Constant-time comparison to prevent timing attacks
817
+ return storedToken === token;
818
+ }
819
+
820
+ private generatePortToken(): string {
821
+ // Generate cryptographically secure 16-character token using Web Crypto API
822
+ // Available in Cloudflare Workers runtime
823
+ const array = new Uint8Array(12); // 12 bytes = 16 base64url chars (after padding removal)
824
+ crypto.getRandomValues(array);
825
+
826
+ // Convert to base64url format (URL-safe, no padding, lowercase)
827
+ const base64 = btoa(String.fromCharCode(...array));
828
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '').toLowerCase();
829
+ }
830
+
831
+ private async persistPortTokens(): Promise<void> {
832
+ // Convert Map to plain object for storage
833
+ const tokensObj: Record<string, string> = {};
834
+ for (const [port, token] of this.portTokens.entries()) {
835
+ tokensObj[port.toString()] = token;
836
+ }
837
+ await this.ctx.storage.put('portTokens', tokensObj);
838
+ }
839
+
840
+ private constructPreviewUrl(port: number, sandboxId: string, hostname: string, token: string): string {
841
+ if (!validatePort(port)) {
842
+ throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
648
843
  }
649
844
 
845
+ // Validate sandbox ID (will throw SecurityError if invalid)
846
+ const sanitizedSandboxId = sanitizeSandboxId(sandboxId);
847
+
650
848
  const isLocalhost = isLocalhostPattern(hostname);
651
849
 
652
850
  if (isLocalhost) {
653
851
  // Unified subdomain approach for localhost (RFC 6761)
654
- const [host, portStr] = hostname.split(":");
655
- const mainPort = portStr || "80";
852
+ const [host, portStr] = hostname.split(':');
853
+ const mainPort = portStr || '80';
656
854
 
657
855
  // Use URL constructor for safe URL building
658
856
  try {
659
857
  const baseUrl = new URL(`http://${host}:${mainPort}`);
660
- // Construct subdomain safely
661
- const subdomainHost = `${port}-${sanitizedSandboxId}.${host}`;
858
+ // Construct subdomain safely with mandatory token
859
+ const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${host}`;
662
860
  baseUrl.hostname = subdomainHost;
663
861
 
664
- const finalUrl = baseUrl.toString();
665
-
666
- logSecurityEvent(
667
- "PREVIEW_URL_CONSTRUCTED",
668
- {
669
- port,
670
- sandboxId: sanitizedSandboxId,
671
- hostname,
672
- resultUrl: finalUrl,
673
- environment: "localhost",
674
- },
675
- "low"
676
- );
677
-
678
- return finalUrl;
862
+ return baseUrl.toString();
679
863
  } catch (error) {
680
- logSecurityEvent(
681
- "URL_CONSTRUCTION_FAILED",
682
- {
683
- port,
684
- sandboxId: sanitizedSandboxId,
685
- hostname,
686
- error: error instanceof Error ? error.message : "Unknown error",
687
- },
688
- "high"
689
- );
690
- throw new SecurityError(
691
- `Failed to construct preview URL: ${
692
- error instanceof Error ? error.message : "Unknown error"
693
- }`
694
- );
864
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
695
865
  }
696
866
  }
697
867
 
@@ -701,87 +871,142 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
701
871
  const protocol = "https";
702
872
  const baseUrl = new URL(`${protocol}://${hostname}`);
703
873
 
704
- // Construct subdomain safely
705
- const subdomainHost = `${port}-${sanitizedSandboxId}.${hostname}`;
874
+ // Construct subdomain safely with mandatory token
875
+ const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
706
876
  baseUrl.hostname = subdomainHost;
707
877
 
708
- const finalUrl = baseUrl.toString();
709
-
710
- logSecurityEvent(
711
- "PREVIEW_URL_CONSTRUCTED",
712
- {
713
- port,
714
- sandboxId: sanitizedSandboxId,
715
- hostname,
716
- resultUrl: finalUrl,
717
- environment: "production",
718
- },
719
- "low"
720
- );
721
-
722
- return finalUrl;
878
+ return baseUrl.toString();
723
879
  } catch (error) {
724
- logSecurityEvent(
725
- "URL_CONSTRUCTION_FAILED",
726
- {
727
- port,
728
- sandboxId: sanitizedSandboxId,
729
- hostname,
730
- error: error instanceof Error ? error.message : "Unknown error",
731
- },
732
- "high"
733
- );
734
- throw new SecurityError(
735
- `Failed to construct preview URL: ${
736
- error instanceof Error ? error.message : "Unknown error"
737
- }`
738
- );
880
+ throw new SecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
739
881
  }
740
882
  }
741
883
 
742
- // Code Interpreter Methods
884
+ // ============================================================================
885
+ // Session Management - Advanced Use Cases
886
+ // ============================================================================
743
887
 
744
888
  /**
745
- * Create a new code execution context
889
+ * Create isolated execution session for advanced use cases
890
+ * Returns ExecutionSession with full sandbox API bound to specific session
746
891
  */
747
- async createCodeContext(
748
- options?: CreateContextOptions
749
- ): Promise<CodeContext> {
750
- return this.codeInterpreter.createCodeContext(options);
892
+ async createSession(options?: SessionOptions): Promise<ExecutionSession> {
893
+ const sessionId = options?.id || `session-${Date.now()}`;
894
+
895
+ // Create session in container
896
+ await this.client.utils.createSession({
897
+ id: sessionId,
898
+ env: options?.env,
899
+ cwd: options?.cwd,
900
+ });
901
+
902
+ // Return wrapper that binds sessionId to all operations
903
+ return this.getSessionWrapper(sessionId);
751
904
  }
752
905
 
753
906
  /**
754
- * Run code with streaming callbacks
907
+ * Get an existing session by ID
908
+ * Returns ExecutionSession wrapper bound to the specified session
909
+ *
910
+ * This is useful for retrieving sessions across different requests/contexts
911
+ * without storing the ExecutionSession object (which has RPC lifecycle limitations)
912
+ *
913
+ * @param sessionId - The ID of an existing session
914
+ * @returns ExecutionSession wrapper bound to the session
755
915
  */
756
- async runCode(
757
- code: string,
758
- options?: RunCodeOptions
759
- ): Promise<ExecutionResult> {
760
- const execution = await this.codeInterpreter.runCode(code, options);
761
- // Convert to plain object for RPC serialization
762
- return execution.toJSON();
916
+ async getSession(sessionId: string): Promise<ExecutionSession> {
917
+ // No need to verify session exists in container - operations will fail naturally if it doesn't
918
+ return this.getSessionWrapper(sessionId);
763
919
  }
764
920
 
765
921
  /**
766
- * Run code and return a streaming response
922
+ * Internal helper to create ExecutionSession wrapper for a given sessionId
923
+ * Used by both createSession and getSession
767
924
  */
768
- async runCodeStream(
769
- code: string,
770
- options?: RunCodeOptions
771
- ): Promise<ReadableStream> {
925
+ private getSessionWrapper(sessionId: string): ExecutionSession {
926
+ return {
927
+ id: sessionId,
928
+
929
+ // Command execution - delegate to internal session-aware methods
930
+ exec: (command, options) => this.execWithSession(command, sessionId, options),
931
+ execStream: (command, options) => this.execStreamWithSession(command, sessionId, options),
932
+
933
+ // Process management
934
+ startProcess: (command, options) => this.startProcess(command, options, sessionId),
935
+ listProcesses: () => this.listProcesses(sessionId),
936
+ getProcess: (id) => this.getProcess(id, sessionId),
937
+ killProcess: (id, signal) => this.killProcess(id, signal),
938
+ killAllProcesses: () => this.killAllProcesses(),
939
+ cleanupCompletedProcesses: () => this.cleanupCompletedProcesses(),
940
+ getProcessLogs: (id) => this.getProcessLogs(id),
941
+ streamProcessLogs: (processId, options) => this.streamProcessLogs(processId, options),
942
+
943
+ // File operations - pass sessionId via options or parameter
944
+ writeFile: (path, content, options) => this.writeFile(path, content, { ...options, sessionId }),
945
+ readFile: (path, options) => this.readFile(path, { ...options, sessionId }),
946
+ readFileStream: (path) => this.readFileStream(path, { sessionId }),
947
+ mkdir: (path, options) => this.mkdir(path, { ...options, sessionId }),
948
+ deleteFile: (path) => this.deleteFile(path, sessionId),
949
+ renameFile: (oldPath, newPath) => this.renameFile(oldPath, newPath, sessionId),
950
+ moveFile: (sourcePath, destPath) => this.moveFile(sourcePath, destPath, sessionId),
951
+ listFiles: (path, options) => this.client.files.listFiles(path, sessionId, options),
952
+ exists: (path) => this.exists(path, sessionId),
953
+
954
+ // Git operations
955
+ gitCheckout: (repoUrl, options) => this.gitCheckout(repoUrl, { ...options, sessionId }),
956
+
957
+ // Environment management - needs special handling
958
+ setEnvVars: async (envVars: Record<string, string>) => {
959
+ try {
960
+ // Set environment variables by executing export commands
961
+ for (const [key, value] of Object.entries(envVars)) {
962
+ const escapedValue = value.replace(/'/g, "'\\''");
963
+ const exportCommand = `export ${key}='${escapedValue}'`;
964
+
965
+ const result = await this.client.commands.execute(exportCommand, sessionId);
966
+
967
+ if (result.exitCode !== 0) {
968
+ throw new Error(`Failed to set ${key}: ${result.stderr || 'Unknown error'}`);
969
+ }
970
+ }
971
+ } catch (error) {
972
+ this.logger.error('Failed to set environment variables', error instanceof Error ? error : new Error(String(error)), { sessionId });
973
+ throw error;
974
+ }
975
+ },
976
+
977
+ // Code interpreter methods - delegate to sandbox's code interpreter
978
+ createCodeContext: (options) => this.codeInterpreter.createCodeContext(options),
979
+ runCode: async (code, options) => {
980
+ const execution = await this.codeInterpreter.runCode(code, options);
981
+ return execution.toJSON();
982
+ },
983
+ runCodeStream: (code, options) => this.codeInterpreter.runCodeStream(code, options),
984
+ listCodeContexts: () => this.codeInterpreter.listCodeContexts(),
985
+ deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId),
986
+ };
987
+ }
988
+
989
+ // ============================================================================
990
+ // Code interpreter methods - delegate to CodeInterpreter wrapper
991
+ // ============================================================================
992
+
993
+ async createCodeContext(options?: CreateContextOptions): Promise<CodeContext> {
994
+ return this.codeInterpreter.createCodeContext(options);
995
+ }
996
+
997
+ async runCode(code: string, options?: RunCodeOptions): Promise<ExecutionResult> {
998
+ const execution = await this.codeInterpreter.runCode(code, options);
999
+ return execution.toJSON();
1000
+ }
1001
+
1002
+ async runCodeStream(code: string, options?: RunCodeOptions): Promise<ReadableStream> {
772
1003
  return this.codeInterpreter.runCodeStream(code, options);
773
1004
  }
774
1005
 
775
- /**
776
- * List all code contexts
777
- */
778
1006
  async listCodeContexts(): Promise<CodeContext[]> {
779
1007
  return this.codeInterpreter.listCodeContexts();
780
1008
  }
781
1009
 
782
- /**
783
- * Delete a code context
784
- */
785
1010
  async deleteCodeContext(contextId: string): Promise<void> {
786
1011
  return this.codeInterpreter.deleteCodeContext(contextId);
787
1012
  }