@cloudflare/sandbox 0.0.0-bb855ca → 0.0.0-c39674b

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