@cloudflare/sandbox 0.0.0-6a2c669 → 0.0.0-6b08f02

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