@cloudflare/sandbox 0.4.18 → 0.5.2

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 (40) hide show
  1. package/.turbo/turbo-build.log +17 -9
  2. package/CHANGELOG.md +64 -0
  3. package/Dockerfile +5 -1
  4. package/LICENSE +176 -0
  5. package/README.md +1 -1
  6. package/dist/dist-gVyG2H2h.js +612 -0
  7. package/dist/dist-gVyG2H2h.js.map +1 -0
  8. package/dist/index.d.ts +94 -1834
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +489 -678
  11. package/dist/index.js.map +1 -1
  12. package/dist/openai/index.d.ts +67 -0
  13. package/dist/openai/index.d.ts.map +1 -0
  14. package/dist/openai/index.js +362 -0
  15. package/dist/openai/index.js.map +1 -0
  16. package/dist/sandbox-B3vJ541e.d.ts +1729 -0
  17. package/dist/sandbox-B3vJ541e.d.ts.map +1 -0
  18. package/package.json +16 -2
  19. package/src/clients/base-client.ts +107 -46
  20. package/src/index.ts +19 -2
  21. package/src/openai/index.ts +465 -0
  22. package/src/request-handler.ts +2 -1
  23. package/src/sandbox.ts +684 -62
  24. package/src/storage-mount/credential-detection.ts +41 -0
  25. package/src/storage-mount/errors.ts +51 -0
  26. package/src/storage-mount/index.ts +17 -0
  27. package/src/storage-mount/provider-detection.ts +93 -0
  28. package/src/storage-mount/types.ts +17 -0
  29. package/src/version.ts +1 -1
  30. package/tests/base-client.test.ts +218 -0
  31. package/tests/get-sandbox.test.ts +24 -1
  32. package/tests/git-client.test.ts +7 -39
  33. package/tests/openai-shell-editor.test.ts +434 -0
  34. package/tests/port-client.test.ts +25 -35
  35. package/tests/process-client.test.ts +73 -107
  36. package/tests/sandbox.test.ts +128 -1
  37. package/tests/storage-mount/credential-detection.test.ts +119 -0
  38. package/tests/storage-mount/provider-detection.test.ts +77 -0
  39. package/tsconfig.json +2 -2
  40. package/tsdown.config.ts +3 -2
package/dist/index.js CHANGED
@@ -1,625 +1,6 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
1
+ import { a as createLogger, c as Execution, d as getEnvString, i as shellEscape, l as ResultImpl, n as isProcess, o as createNoOpLogger, r as isProcessStatus, s as TraceContext, t as isExecResult, u as GitLogger } from "./dist-gVyG2H2h.js";
2
2
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
3
3
 
4
- //#region ../shared/dist/git.js
5
- /**
6
- * Redact credentials from URLs for secure logging
7
- *
8
- * Replaces any credentials (username:password, tokens, etc.) embedded
9
- * in URLs with ****** to prevent sensitive data exposure in logs.
10
- * Works with URLs embedded in text (e.g., "Error: https://token@github.com/repo.git failed")
11
- *
12
- * @param text - String that may contain URLs with credentials
13
- * @returns String with credentials redacted from any URLs
14
- */
15
- function redactCredentials(text) {
16
- let result = text;
17
- let pos = 0;
18
- while (pos < result.length) {
19
- const httpPos = result.indexOf("http://", pos);
20
- const httpsPos = result.indexOf("https://", pos);
21
- let protocolPos = -1;
22
- let protocolLen = 0;
23
- if (httpPos === -1 && httpsPos === -1) break;
24
- if (httpPos !== -1 && (httpsPos === -1 || httpPos < httpsPos)) {
25
- protocolPos = httpPos;
26
- protocolLen = 7;
27
- } else {
28
- protocolPos = httpsPos;
29
- protocolLen = 8;
30
- }
31
- const searchStart = protocolPos + protocolLen;
32
- const atPos = result.indexOf("@", searchStart);
33
- let urlEnd = searchStart;
34
- while (urlEnd < result.length) {
35
- const char = result[urlEnd];
36
- if (/[\s"'`<>,;{}[\]]/.test(char)) break;
37
- urlEnd++;
38
- }
39
- if (atPos !== -1 && atPos < urlEnd) {
40
- result = `${result.substring(0, searchStart)}******${result.substring(atPos)}`;
41
- pos = searchStart + 6;
42
- } else pos = protocolPos + protocolLen;
43
- }
44
- return result;
45
- }
46
- /**
47
- * Sanitize data by redacting credentials from any strings
48
- * Recursively processes objects and arrays to ensure credentials are never leaked
49
- */
50
- function sanitizeGitData(data) {
51
- if (typeof data === "string") return redactCredentials(data);
52
- if (data === null || data === void 0) return data;
53
- if (Array.isArray(data)) return data.map((item) => sanitizeGitData(item));
54
- if (typeof data === "object") {
55
- const result = {};
56
- for (const [key, value] of Object.entries(data)) result[key] = sanitizeGitData(value);
57
- return result;
58
- }
59
- return data;
60
- }
61
- /**
62
- * Logger wrapper that automatically sanitizes git credentials
63
- */
64
- var GitLogger = class GitLogger {
65
- baseLogger;
66
- constructor(baseLogger) {
67
- this.baseLogger = baseLogger;
68
- }
69
- sanitizeContext(context) {
70
- return context ? sanitizeGitData(context) : context;
71
- }
72
- sanitizeError(error) {
73
- if (!error) return error;
74
- const sanitized = new Error(redactCredentials(error.message));
75
- sanitized.name = error.name;
76
- if (error.stack) sanitized.stack = redactCredentials(error.stack);
77
- const sanitizedRecord = sanitized;
78
- const errorRecord = error;
79
- for (const key of Object.keys(error)) if (key !== "message" && key !== "stack" && key !== "name") sanitizedRecord[key] = sanitizeGitData(errorRecord[key]);
80
- return sanitized;
81
- }
82
- debug(message, context) {
83
- this.baseLogger.debug(message, this.sanitizeContext(context));
84
- }
85
- info(message, context) {
86
- this.baseLogger.info(message, this.sanitizeContext(context));
87
- }
88
- warn(message, context) {
89
- this.baseLogger.warn(message, this.sanitizeContext(context));
90
- }
91
- error(message, error, context) {
92
- this.baseLogger.error(message, this.sanitizeError(error), this.sanitizeContext(context));
93
- }
94
- child(context) {
95
- const sanitized = sanitizeGitData(context);
96
- return new GitLogger(this.baseLogger.child(sanitized));
97
- }
98
- };
99
-
100
- //#endregion
101
- //#region ../shared/dist/interpreter-types.js
102
- var Execution = class {
103
- code;
104
- context;
105
- /**
106
- * All results from the execution
107
- */
108
- results = [];
109
- /**
110
- * Accumulated stdout and stderr
111
- */
112
- logs = {
113
- stdout: [],
114
- stderr: []
115
- };
116
- /**
117
- * Execution error if any
118
- */
119
- error;
120
- /**
121
- * Execution count (for interpreter)
122
- */
123
- executionCount;
124
- constructor(code, context) {
125
- this.code = code;
126
- this.context = context;
127
- }
128
- /**
129
- * Convert to a plain object for serialization
130
- */
131
- toJSON() {
132
- return {
133
- code: this.code,
134
- logs: this.logs,
135
- error: this.error,
136
- executionCount: this.executionCount,
137
- results: this.results.map((result) => ({
138
- text: result.text,
139
- html: result.html,
140
- png: result.png,
141
- jpeg: result.jpeg,
142
- svg: result.svg,
143
- latex: result.latex,
144
- markdown: result.markdown,
145
- javascript: result.javascript,
146
- json: result.json,
147
- chart: result.chart,
148
- data: result.data
149
- }))
150
- };
151
- }
152
- };
153
- var ResultImpl = class {
154
- raw;
155
- constructor(raw) {
156
- this.raw = raw;
157
- }
158
- get text() {
159
- return this.raw.text || this.raw.data?.["text/plain"];
160
- }
161
- get html() {
162
- return this.raw.html || this.raw.data?.["text/html"];
163
- }
164
- get png() {
165
- return this.raw.png || this.raw.data?.["image/png"];
166
- }
167
- get jpeg() {
168
- return this.raw.jpeg || this.raw.data?.["image/jpeg"];
169
- }
170
- get svg() {
171
- return this.raw.svg || this.raw.data?.["image/svg+xml"];
172
- }
173
- get latex() {
174
- return this.raw.latex || this.raw.data?.["text/latex"];
175
- }
176
- get markdown() {
177
- return this.raw.markdown || this.raw.data?.["text/markdown"];
178
- }
179
- get javascript() {
180
- return this.raw.javascript || this.raw.data?.["application/javascript"];
181
- }
182
- get json() {
183
- return this.raw.json || this.raw.data?.["application/json"];
184
- }
185
- get chart() {
186
- return this.raw.chart;
187
- }
188
- get data() {
189
- return this.raw.data;
190
- }
191
- formats() {
192
- const formats = [];
193
- if (this.text) formats.push("text");
194
- if (this.html) formats.push("html");
195
- if (this.png) formats.push("png");
196
- if (this.jpeg) formats.push("jpeg");
197
- if (this.svg) formats.push("svg");
198
- if (this.latex) formats.push("latex");
199
- if (this.markdown) formats.push("markdown");
200
- if (this.javascript) formats.push("javascript");
201
- if (this.json) formats.push("json");
202
- if (this.chart) formats.push("chart");
203
- return formats;
204
- }
205
- };
206
-
207
- //#endregion
208
- //#region ../shared/dist/logger/types.js
209
- /**
210
- * Logger types for Cloudflare Sandbox SDK
211
- *
212
- * Provides structured, trace-aware logging across Worker, Durable Object, and Container.
213
- */
214
- /**
215
- * Log levels (from most to least verbose)
216
- */
217
- var LogLevel;
218
- (function(LogLevel$1) {
219
- LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
220
- LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
221
- LogLevel$1[LogLevel$1["WARN"] = 2] = "WARN";
222
- LogLevel$1[LogLevel$1["ERROR"] = 3] = "ERROR";
223
- })(LogLevel || (LogLevel = {}));
224
-
225
- //#endregion
226
- //#region ../shared/dist/logger/logger.js
227
- /**
228
- * ANSI color codes for terminal output
229
- */
230
- const COLORS = {
231
- reset: "\x1B[0m",
232
- debug: "\x1B[36m",
233
- info: "\x1B[32m",
234
- warn: "\x1B[33m",
235
- error: "\x1B[31m",
236
- dim: "\x1B[2m"
237
- };
238
- /**
239
- * CloudflareLogger implements structured logging with support for
240
- * both JSON output (production) and pretty printing (development).
241
- */
242
- var CloudflareLogger = class CloudflareLogger {
243
- baseContext;
244
- minLevel;
245
- pretty;
246
- /**
247
- * Create a new CloudflareLogger
248
- *
249
- * @param baseContext Base context included in all log entries
250
- * @param minLevel Minimum log level to output (default: INFO)
251
- * @param pretty Enable pretty printing for human-readable output (default: false)
252
- */
253
- constructor(baseContext, minLevel = LogLevel.INFO, pretty = false) {
254
- this.baseContext = baseContext;
255
- this.minLevel = minLevel;
256
- this.pretty = pretty;
257
- }
258
- /**
259
- * Log debug-level message
260
- */
261
- debug(message, context) {
262
- if (this.shouldLog(LogLevel.DEBUG)) {
263
- const logData = this.buildLogData("debug", message, context);
264
- this.output(console.log, logData);
265
- }
266
- }
267
- /**
268
- * Log info-level message
269
- */
270
- info(message, context) {
271
- if (this.shouldLog(LogLevel.INFO)) {
272
- const logData = this.buildLogData("info", message, context);
273
- this.output(console.log, logData);
274
- }
275
- }
276
- /**
277
- * Log warning-level message
278
- */
279
- warn(message, context) {
280
- if (this.shouldLog(LogLevel.WARN)) {
281
- const logData = this.buildLogData("warn", message, context);
282
- this.output(console.warn, logData);
283
- }
284
- }
285
- /**
286
- * Log error-level message
287
- */
288
- error(message, error, context) {
289
- if (this.shouldLog(LogLevel.ERROR)) {
290
- const logData = this.buildLogData("error", message, context, error);
291
- this.output(console.error, logData);
292
- }
293
- }
294
- /**
295
- * Create a child logger with additional context
296
- */
297
- child(context) {
298
- return new CloudflareLogger({
299
- ...this.baseContext,
300
- ...context
301
- }, this.minLevel, this.pretty);
302
- }
303
- /**
304
- * Check if a log level should be output
305
- */
306
- shouldLog(level) {
307
- return level >= this.minLevel;
308
- }
309
- /**
310
- * Build log data object
311
- */
312
- buildLogData(level, message, context, error) {
313
- const logData = {
314
- level,
315
- msg: message,
316
- ...this.baseContext,
317
- ...context,
318
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
319
- };
320
- if (error) logData.error = {
321
- message: error.message,
322
- stack: error.stack,
323
- name: error.name
324
- };
325
- return logData;
326
- }
327
- /**
328
- * Output log data to console (pretty or JSON)
329
- */
330
- output(consoleFn, data) {
331
- if (this.pretty) this.outputPretty(consoleFn, data);
332
- else this.outputJson(consoleFn, data);
333
- }
334
- /**
335
- * Output as JSON (production)
336
- */
337
- outputJson(consoleFn, data) {
338
- consoleFn(JSON.stringify(data));
339
- }
340
- /**
341
- * Output as pretty-printed, colored text (development)
342
- *
343
- * Format: LEVEL [component] message (trace: tr_...) {context}
344
- * Example: INFO [sandbox-do] Command started (trace: tr_7f3a9b2c) {commandId: "cmd-123"}
345
- */
346
- outputPretty(consoleFn, data) {
347
- const { level, msg, timestamp, traceId, component, sandboxId, sessionId, processId, commandId, operation, duration, error,...rest } = data;
348
- const levelStr = String(level || "INFO").toUpperCase();
349
- const levelColor = this.getLevelColor(levelStr);
350
- const componentBadge = component ? `[${component}]` : "";
351
- const traceIdShort = traceId ? String(traceId).substring(0, 12) : "";
352
- let logLine = `${levelColor}${levelStr.padEnd(5)}${COLORS.reset} ${componentBadge} ${msg}`;
353
- if (traceIdShort) logLine += ` ${COLORS.dim}(trace: ${traceIdShort})${COLORS.reset}`;
354
- const contextFields = [];
355
- if (operation) contextFields.push(`operation: ${operation}`);
356
- if (commandId) contextFields.push(`commandId: ${String(commandId).substring(0, 12)}`);
357
- if (sandboxId) contextFields.push(`sandboxId: ${sandboxId}`);
358
- if (sessionId) contextFields.push(`sessionId: ${String(sessionId).substring(0, 12)}`);
359
- if (processId) contextFields.push(`processId: ${processId}`);
360
- if (duration !== void 0) contextFields.push(`duration: ${duration}ms`);
361
- if (contextFields.length > 0) logLine += ` ${COLORS.dim}{${contextFields.join(", ")}}${COLORS.reset}`;
362
- consoleFn(logLine);
363
- if (error && typeof error === "object") {
364
- const errorObj = error;
365
- if (errorObj.message) consoleFn(` ${COLORS.error}Error: ${errorObj.message}${COLORS.reset}`);
366
- if (errorObj.stack) consoleFn(` ${COLORS.dim}${errorObj.stack}${COLORS.reset}`);
367
- }
368
- if (Object.keys(rest).length > 0) consoleFn(` ${COLORS.dim}${JSON.stringify(rest, null, 2)}${COLORS.reset}`);
369
- }
370
- /**
371
- * Get ANSI color code for log level
372
- */
373
- getLevelColor(level) {
374
- switch (level.toLowerCase()) {
375
- case "debug": return COLORS.debug;
376
- case "info": return COLORS.info;
377
- case "warn": return COLORS.warn;
378
- case "error": return COLORS.error;
379
- default: return COLORS.reset;
380
- }
381
- }
382
- };
383
-
384
- //#endregion
385
- //#region ../shared/dist/logger/trace-context.js
386
- /**
387
- * Trace context utilities for request correlation
388
- *
389
- * Trace IDs enable correlating logs across distributed components:
390
- * Worker → Durable Object → Container → back
391
- *
392
- * The trace ID is propagated via the X-Trace-Id HTTP header.
393
- */
394
- /**
395
- * Utility for managing trace context across distributed components
396
- */
397
- var TraceContext = class TraceContext {
398
- /**
399
- * HTTP header name for trace ID propagation
400
- */
401
- static TRACE_HEADER = "X-Trace-Id";
402
- /**
403
- * Generate a new trace ID
404
- *
405
- * Format: "tr_" + 16 random hex characters
406
- * Example: "tr_7f3a9b2c4e5d6f1a"
407
- *
408
- * @returns Newly generated trace ID
409
- */
410
- static generate() {
411
- return `tr_${crypto.randomUUID().replace(/-/g, "").substring(0, 16)}`;
412
- }
413
- /**
414
- * Extract trace ID from HTTP request headers
415
- *
416
- * @param headers Request headers
417
- * @returns Trace ID if present, null otherwise
418
- */
419
- static fromHeaders(headers) {
420
- return headers.get(TraceContext.TRACE_HEADER);
421
- }
422
- /**
423
- * Create headers object with trace ID for outgoing requests
424
- *
425
- * @param traceId Trace ID to include
426
- * @returns Headers object with X-Trace-Id set
427
- */
428
- static toHeaders(traceId) {
429
- return { [TraceContext.TRACE_HEADER]: traceId };
430
- }
431
- /**
432
- * Get the header name used for trace ID propagation
433
- *
434
- * @returns Header name ("X-Trace-Id")
435
- */
436
- static getHeaderName() {
437
- return TraceContext.TRACE_HEADER;
438
- }
439
- };
440
-
441
- //#endregion
442
- //#region ../shared/dist/logger/index.js
443
- /**
444
- * Create a no-op logger for testing
445
- *
446
- * Returns a logger that implements the Logger interface but does nothing.
447
- * Useful for tests that don't need actual logging output.
448
- *
449
- * @returns No-op logger instance
450
- *
451
- * @example
452
- * ```typescript
453
- * // In tests
454
- * const client = new HttpClient({
455
- * baseUrl: 'http://test.com',
456
- * logger: createNoOpLogger() // Optional - tests can enable real logging if needed
457
- * });
458
- * ```
459
- */
460
- function createNoOpLogger() {
461
- return {
462
- debug: () => {},
463
- info: () => {},
464
- warn: () => {},
465
- error: () => {},
466
- child: () => createNoOpLogger()
467
- };
468
- }
469
- /**
470
- * AsyncLocalStorage for logger context
471
- *
472
- * Enables implicit logger propagation throughout the call stack without
473
- * explicit parameter passing. The logger is stored per async context.
474
- */
475
- const loggerStorage = new AsyncLocalStorage();
476
- /**
477
- * Get the current logger from AsyncLocalStorage
478
- *
479
- * @throws Error if no logger is initialized in the current async context
480
- * @returns Current logger instance
481
- *
482
- * @example
483
- * ```typescript
484
- * function someHelperFunction() {
485
- * const logger = getLogger(); // Automatically has all context!
486
- * logger.info('Helper called');
487
- * }
488
- * ```
489
- */
490
- function getLogger() {
491
- const logger = loggerStorage.getStore();
492
- if (!logger) throw new Error("Logger not initialized in async context. Ensure runWithLogger() is called at the entry point (e.g., fetch handler).");
493
- return logger;
494
- }
495
- /**
496
- * Run a function with a logger stored in AsyncLocalStorage
497
- *
498
- * The logger is available to all code within the function via getLogger().
499
- * This is typically called at request entry points (fetch handler) and when
500
- * creating child loggers with additional context.
501
- *
502
- * @param logger Logger instance to store in context
503
- * @param fn Function to execute with logger context
504
- * @returns Result of the function
505
- *
506
- * @example
507
- * ```typescript
508
- * // At request entry point
509
- * async fetch(request: Request): Promise<Response> {
510
- * const logger = createLogger({ component: 'sandbox-do', traceId: 'tr_abc' });
511
- * return runWithLogger(logger, async () => {
512
- * return await this.handleRequest(request);
513
- * });
514
- * }
515
- *
516
- * // When adding operation context
517
- * async exec(command: string) {
518
- * const logger = getLogger().child({ operation: 'exec', commandId: 'cmd-123' });
519
- * return runWithLogger(logger, async () => {
520
- * logger.info('Command started');
521
- * await this.executeCommand(command); // Nested calls get the child logger
522
- * logger.info('Command completed');
523
- * });
524
- * }
525
- * ```
526
- */
527
- function runWithLogger(logger, fn) {
528
- return loggerStorage.run(logger, fn);
529
- }
530
- /**
531
- * Create a new logger instance
532
- *
533
- * @param context Base context for the logger. Must include 'component'.
534
- * TraceId will be auto-generated if not provided.
535
- * @returns New logger instance
536
- *
537
- * @example
538
- * ```typescript
539
- * // In Durable Object
540
- * const logger = createLogger({
541
- * component: 'sandbox-do',
542
- * traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
543
- * sandboxId: this.id
544
- * });
545
- *
546
- * // In Container
547
- * const logger = createLogger({
548
- * component: 'container',
549
- * traceId: TraceContext.fromHeaders(request.headers)!,
550
- * sessionId: this.id
551
- * });
552
- * ```
553
- */
554
- function createLogger(context) {
555
- const minLevel = getLogLevelFromEnv();
556
- const pretty = isPrettyPrintEnabled();
557
- return new CloudflareLogger({
558
- ...context,
559
- traceId: context.traceId || TraceContext.generate(),
560
- component: context.component
561
- }, minLevel, pretty);
562
- }
563
- /**
564
- * Get log level from environment variable
565
- *
566
- * Checks SANDBOX_LOG_LEVEL env var, falls back to default based on environment.
567
- * Default: 'debug' for development, 'info' for production
568
- */
569
- function getLogLevelFromEnv() {
570
- switch ((getEnvVar("SANDBOX_LOG_LEVEL") || "info").toLowerCase()) {
571
- case "debug": return LogLevel.DEBUG;
572
- case "info": return LogLevel.INFO;
573
- case "warn": return LogLevel.WARN;
574
- case "error": return LogLevel.ERROR;
575
- default: return LogLevel.INFO;
576
- }
577
- }
578
- /**
579
- * Check if pretty printing should be enabled
580
- *
581
- * Checks SANDBOX_LOG_FORMAT env var, falls back to auto-detection:
582
- * - Local development: pretty (colored, human-readable)
583
- * - Production: json (structured)
584
- */
585
- function isPrettyPrintEnabled() {
586
- const format = getEnvVar("SANDBOX_LOG_FORMAT");
587
- if (format) return format.toLowerCase() === "pretty";
588
- return false;
589
- }
590
- /**
591
- * Get environment variable value
592
- *
593
- * Supports both Node.js (process.env) and Bun (Bun.env)
594
- */
595
- function getEnvVar(name) {
596
- if (typeof process !== "undefined" && process.env) return process.env[name];
597
- if (typeof Bun !== "undefined") {
598
- const bunEnv = Bun.env;
599
- if (bunEnv) return bunEnv[name];
600
- }
601
- }
602
-
603
- //#endregion
604
- //#region ../shared/dist/types.js
605
- function isExecResult(value) {
606
- return value && typeof value.success === "boolean" && typeof value.exitCode === "number" && typeof value.stdout === "string" && typeof value.stderr === "string";
607
- }
608
- function isProcess(value) {
609
- return value && typeof value.id === "string" && typeof value.command === "string" && typeof value.status === "string";
610
- }
611
- function isProcessStatus(value) {
612
- return [
613
- "starting",
614
- "running",
615
- "completed",
616
- "failed",
617
- "killed",
618
- "error"
619
- ].includes(value);
620
- }
621
-
622
- //#endregion
623
4
  //#region ../shared/dist/errors/codes.js
624
5
  /**
625
6
  * Centralized error code registry
@@ -662,6 +43,10 @@ const ErrorCode = {
662
43
  GIT_CLONE_FAILED: "GIT_CLONE_FAILED",
663
44
  GIT_CHECKOUT_FAILED: "GIT_CHECKOUT_FAILED",
664
45
  GIT_OPERATION_FAILED: "GIT_OPERATION_FAILED",
46
+ BUCKET_MOUNT_ERROR: "BUCKET_MOUNT_ERROR",
47
+ S3FS_MOUNT_ERROR: "S3FS_MOUNT_ERROR",
48
+ MISSING_CREDENTIALS: "MISSING_CREDENTIALS",
49
+ INVALID_MOUNT_CONFIG: "INVALID_MOUNT_CONFIG",
665
50
  INTERPRETER_NOT_READY: "INTERPRETER_NOT_READY",
666
51
  CONTEXT_NOT_FOUND: "CONTEXT_NOT_FOUND",
667
52
  CODE_EXECUTION_ERROR: "CODE_EXECUTION_ERROR",
@@ -695,6 +80,8 @@ const ERROR_STATUS_MAP = {
695
80
  [ErrorCode.INVALID_JSON_RESPONSE]: 400,
696
81
  [ErrorCode.NAME_TOO_LONG]: 400,
697
82
  [ErrorCode.VALIDATION_FAILED]: 400,
83
+ [ErrorCode.MISSING_CREDENTIALS]: 400,
84
+ [ErrorCode.INVALID_MOUNT_CONFIG]: 400,
698
85
  [ErrorCode.GIT_AUTH_FAILED]: 401,
699
86
  [ErrorCode.PERMISSION_DENIED]: 403,
700
87
  [ErrorCode.COMMAND_PERMISSION_DENIED]: 403,
@@ -719,6 +106,8 @@ const ERROR_STATUS_MAP = {
719
106
  [ErrorCode.GIT_CHECKOUT_FAILED]: 500,
720
107
  [ErrorCode.GIT_OPERATION_FAILED]: 500,
721
108
  [ErrorCode.CODE_EXECUTION_ERROR]: 500,
109
+ [ErrorCode.BUCKET_MOUNT_ERROR]: 500,
110
+ [ErrorCode.S3FS_MOUNT_ERROR]: 500,
722
111
  [ErrorCode.UNKNOWN_ERROR]: 500,
723
112
  [ErrorCode.INTERNAL_ERROR]: 500
724
113
  };
@@ -1237,8 +626,8 @@ function createErrorFromResponse(errorResponse) {
1237
626
 
1238
627
  //#endregion
1239
628
  //#region src/clients/base-client.ts
1240
- const TIMEOUT_MS = 6e4;
1241
- const MIN_TIME_FOR_RETRY_MS = 1e4;
629
+ const TIMEOUT_MS = 12e4;
630
+ const MIN_TIME_FOR_RETRY_MS = 15e3;
1242
631
  /**
1243
632
  * Abstract base class providing common HTTP functionality for all domain clients
1244
633
  */
@@ -1252,31 +641,31 @@ var BaseHttpClient = class {
1252
641
  this.baseUrl = this.options.baseUrl;
1253
642
  }
1254
643
  /**
1255
- * Core HTTP request method with automatic retry for container provisioning delays
644
+ * Core HTTP request method with automatic retry for container startup delays
645
+ * Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related
1256
646
  */
1257
647
  async doFetch(path, options) {
1258
648
  const startTime = Date.now();
1259
649
  let attempt = 0;
1260
650
  while (true) {
1261
651
  const response = await this.executeFetch(path, options);
1262
- if (response.status === 503) {
1263
- if (await this.isContainerProvisioningError(response)) {
1264
- const remaining = TIMEOUT_MS - (Date.now() - startTime);
1265
- if (remaining > MIN_TIME_FOR_RETRY_MS) {
1266
- const delay = Math.min(2e3 * 2 ** attempt, 16e3);
1267
- this.logger.info("Container provisioning in progress, retrying", {
1268
- attempt: attempt + 1,
1269
- delayMs: delay,
1270
- remainingSec: Math.floor(remaining / 1e3)
1271
- });
1272
- await new Promise((resolve) => setTimeout(resolve, delay));
1273
- attempt++;
1274
- continue;
1275
- } else {
1276
- this.logger.error("Container failed to provision after multiple attempts", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over 60s`));
1277
- return response;
1278
- }
652
+ if (await this.isRetryableContainerError(response)) {
653
+ const elapsed = Date.now() - startTime;
654
+ const remaining = TIMEOUT_MS - elapsed;
655
+ if (remaining > MIN_TIME_FOR_RETRY_MS) {
656
+ const delay = Math.min(3e3 * 2 ** attempt, 3e4);
657
+ this.logger.info("Container not ready, retrying", {
658
+ status: response.status,
659
+ attempt: attempt + 1,
660
+ delayMs: delay,
661
+ remainingSec: Math.floor(remaining / 1e3)
662
+ });
663
+ await new Promise((resolve) => setTimeout(resolve, delay));
664
+ attempt++;
665
+ continue;
1279
666
  }
667
+ this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1e3)}s`));
668
+ return response;
1280
669
  }
1281
670
  return response;
1282
671
  }
@@ -1372,14 +761,50 @@ var BaseHttpClient = class {
1372
761
  } else this.logger.error(`Error in ${operation}`, error instanceof Error ? error : new Error(String(error)));
1373
762
  }
1374
763
  /**
1375
- * Check if 503 response is from container provisioning (retryable)
1376
- * vs user application (not retryable)
764
+ * Check if response indicates a retryable container error
765
+ * Uses fail-safe strategy: only retry known transient errors
766
+ *
767
+ * TODO: This relies on string matching error messages, which is brittle.
768
+ * Ideally, the container API should return structured errors with a
769
+ * `retryable: boolean` field to avoid coupling to error message format.
770
+ *
771
+ * @param response - HTTP response to check
772
+ * @returns true if error is retryable container error, false otherwise
1377
773
  */
1378
- async isContainerProvisioningError(response) {
774
+ async isRetryableContainerError(response) {
775
+ if (response.status !== 500 && response.status !== 503) return false;
1379
776
  try {
1380
- return (await response.clone().text()).includes("There is no Container instance available");
777
+ const text = await response.clone().text();
778
+ const textLower = text.toLowerCase();
779
+ if ([
780
+ "no such image",
781
+ "container already exists",
782
+ "malformed containerinspect"
783
+ ].some((err) => textLower.includes(err))) {
784
+ this.logger.debug("Detected permanent error, not retrying", { text });
785
+ return false;
786
+ }
787
+ const shouldRetry = [
788
+ "no container instance available",
789
+ "currently provisioning",
790
+ "container port not found",
791
+ "connection refused: container port",
792
+ "the container is not listening",
793
+ "failed to verify port",
794
+ "container did not start",
795
+ "network connection lost",
796
+ "container suddenly disconnected",
797
+ "monitor failed to find container",
798
+ "timed out",
799
+ "timeout"
800
+ ].some((err) => textLower.includes(err));
801
+ if (!shouldRetry) this.logger.debug("Unknown error pattern, not retrying", {
802
+ status: response.status,
803
+ text: text.substring(0, 200)
804
+ });
805
+ return shouldRetry;
1381
806
  } catch (error) {
1382
- this.logger.error("Error checking response body", error instanceof Error ? error : new Error(String(error)));
807
+ this.logger.error("Error checking if response is retryable", error instanceof Error ? error : new Error(String(error)));
1383
808
  return false;
1384
809
  }
1385
810
  }
@@ -2333,7 +1758,7 @@ async function proxyToSandbox(request, env) {
2333
1758
  const routeInfo = extractSandboxRoute(url);
2334
1759
  if (!routeInfo) return null;
2335
1760
  const { sandboxId, port, path, token } = routeInfo;
2336
- const sandbox = getSandbox(env.Sandbox, sandboxId);
1761
+ const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
2337
1762
  if (port !== 3e3) {
2338
1763
  if (!await sandbox.validatePortToken(port, token)) {
2339
1764
  logger.warn("Invalid token access blocked", {
@@ -2492,6 +1917,124 @@ function asyncIterableToSSEStream(events, options) {
2492
1917
  });
2493
1918
  }
2494
1919
 
1920
+ //#endregion
1921
+ //#region src/storage-mount/errors.ts
1922
+ /**
1923
+ * Bucket mounting error classes
1924
+ *
1925
+ * These are SDK-side validation errors that follow the same pattern as SecurityError.
1926
+ * They are thrown before any container interaction occurs.
1927
+ */
1928
+ /**
1929
+ * Base error for bucket mounting operations
1930
+ */
1931
+ var BucketMountError = class extends Error {
1932
+ code;
1933
+ constructor(message, code = ErrorCode.BUCKET_MOUNT_ERROR) {
1934
+ super(message);
1935
+ this.name = "BucketMountError";
1936
+ this.code = code;
1937
+ }
1938
+ };
1939
+ /**
1940
+ * Thrown when S3FS mount command fails
1941
+ */
1942
+ var S3FSMountError = class extends BucketMountError {
1943
+ constructor(message) {
1944
+ super(message, ErrorCode.S3FS_MOUNT_ERROR);
1945
+ this.name = "S3FSMountError";
1946
+ }
1947
+ };
1948
+ /**
1949
+ * Thrown when no credentials found in environment
1950
+ */
1951
+ var MissingCredentialsError = class extends BucketMountError {
1952
+ constructor(message) {
1953
+ super(message, ErrorCode.MISSING_CREDENTIALS);
1954
+ this.name = "MissingCredentialsError";
1955
+ }
1956
+ };
1957
+ /**
1958
+ * Thrown when bucket name, mount path, or options are invalid
1959
+ */
1960
+ var InvalidMountConfigError = class extends BucketMountError {
1961
+ constructor(message) {
1962
+ super(message, ErrorCode.INVALID_MOUNT_CONFIG);
1963
+ this.name = "InvalidMountConfigError";
1964
+ }
1965
+ };
1966
+
1967
+ //#endregion
1968
+ //#region src/storage-mount/credential-detection.ts
1969
+ /**
1970
+ * Detect credentials for bucket mounting from environment variables
1971
+ * Priority order:
1972
+ * 1. Explicit options.credentials
1973
+ * 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
1974
+ * 3. Error: no credentials found
1975
+ *
1976
+ * @param options - Mount options
1977
+ * @param envVars - Environment variables
1978
+ * @returns Detected credentials
1979
+ * @throws MissingCredentialsError if no credentials found
1980
+ */
1981
+ function detectCredentials(options, envVars) {
1982
+ if (options.credentials) return options.credentials;
1983
+ const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
1984
+ const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
1985
+ if (awsAccessKeyId && awsSecretAccessKey) return {
1986
+ accessKeyId: awsAccessKeyId,
1987
+ secretAccessKey: awsSecretAccessKey
1988
+ };
1989
+ throw new MissingCredentialsError("No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or pass explicit credentials in options.");
1990
+ }
1991
+
1992
+ //#endregion
1993
+ //#region src/storage-mount/provider-detection.ts
1994
+ /**
1995
+ * Detect provider from endpoint URL using pattern matching
1996
+ */
1997
+ function detectProviderFromUrl(endpoint) {
1998
+ try {
1999
+ const hostname = new URL(endpoint).hostname.toLowerCase();
2000
+ if (hostname.endsWith(".r2.cloudflarestorage.com")) return "r2";
2001
+ if (hostname.endsWith(".amazonaws.com") || hostname === "s3.amazonaws.com") return "s3";
2002
+ if (hostname === "storage.googleapis.com") return "gcs";
2003
+ return null;
2004
+ } catch {
2005
+ return null;
2006
+ }
2007
+ }
2008
+ /**
2009
+ * Get s3fs flags for a given provider
2010
+ *
2011
+ * Based on s3fs-fuse wiki recommendations:
2012
+ * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
2013
+ */
2014
+ function getProviderFlags(provider) {
2015
+ if (!provider) return ["use_path_request_style"];
2016
+ switch (provider) {
2017
+ case "r2": return ["nomixupload"];
2018
+ case "s3": return [];
2019
+ case "gcs": return [];
2020
+ default: return ["use_path_request_style"];
2021
+ }
2022
+ }
2023
+ /**
2024
+ * Resolve s3fs options by combining provider defaults with user overrides
2025
+ */
2026
+ function resolveS3fsOptions(provider, userOptions) {
2027
+ const providerFlags = getProviderFlags(provider);
2028
+ if (!userOptions || userOptions.length === 0) return providerFlags;
2029
+ const allFlags = [...providerFlags, ...userOptions];
2030
+ const flagMap = /* @__PURE__ */ new Map();
2031
+ for (const flag of allFlags) {
2032
+ const [flagName] = flag.split("=");
2033
+ flagMap.set(flagName, flag);
2034
+ }
2035
+ return Array.from(flagMap.values());
2036
+ }
2037
+
2495
2038
  //#endregion
2496
2039
  //#region src/version.ts
2497
2040
  /**
@@ -2499,16 +2042,21 @@ function asyncIterableToSSEStream(events, options) {
2499
2042
  * This file is auto-updated by .github/changeset-version.ts during releases
2500
2043
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
2501
2044
  */
2502
- const SDK_VERSION = "0.4.18";
2045
+ const SDK_VERSION = "0.5.2";
2503
2046
 
2504
2047
  //#endregion
2505
2048
  //#region src/sandbox.ts
2506
2049
  function getSandbox(ns, id, options) {
2507
- const stub = getContainer(ns, id);
2508
- stub.setSandboxName?.(id);
2050
+ const sanitizedId = sanitizeSandboxId(id);
2051
+ const effectiveId = options?.normalizeId ? sanitizedId.toLowerCase() : sanitizedId;
2052
+ const hasUppercase = /[A-Z]/.test(sanitizedId);
2053
+ if (!options?.normalizeId && hasUppercase) createLogger({ component: "sandbox-do" }).warn(`Sandbox ID "${sanitizedId}" contains uppercase letters, which causes issues with preview URLs (hostnames are case-insensitive). normalizeId will default to true in a future version to prevent this. Use lowercase IDs or pass { normalizeId: true } to prepare.`);
2054
+ const stub = getContainer(ns, effectiveId);
2055
+ stub.setSandboxName?.(effectiveId, options?.normalizeId);
2509
2056
  if (options?.baseUrl) stub.setBaseUrl(options.baseUrl);
2510
2057
  if (options?.sleepAfter !== void 0) stub.setSleepAfter(options.sleepAfter);
2511
2058
  if (options?.keepAlive !== void 0) stub.setKeepAlive(options.keepAlive);
2059
+ if (options?.containerTimeouts) stub.setContainerTimeouts(options.containerTimeouts);
2512
2060
  return Object.assign(stub, { wsConnect: connect(stub) });
2513
2061
  }
2514
2062
  function connect(stub) {
@@ -2524,18 +2072,35 @@ var Sandbox = class extends Container {
2524
2072
  client;
2525
2073
  codeInterpreter;
2526
2074
  sandboxName = null;
2075
+ normalizeId = false;
2527
2076
  baseUrl = null;
2528
2077
  portTokens = /* @__PURE__ */ new Map();
2529
2078
  defaultSession = null;
2530
2079
  envVars = {};
2531
2080
  logger;
2532
2081
  keepAliveEnabled = false;
2082
+ activeMounts = /* @__PURE__ */ new Map();
2083
+ /**
2084
+ * Default container startup timeouts (conservative for production)
2085
+ * Based on Cloudflare docs: "Containers take several minutes to provision"
2086
+ */
2087
+ DEFAULT_CONTAINER_TIMEOUTS = {
2088
+ instanceGetTimeoutMS: 3e4,
2089
+ portReadyTimeoutMS: 9e4,
2090
+ waitIntervalMS: 1e3
2091
+ };
2092
+ /**
2093
+ * Active container timeout configuration
2094
+ * Can be set via options, env vars, or defaults
2095
+ */
2096
+ containerTimeouts = { ...this.DEFAULT_CONTAINER_TIMEOUTS };
2533
2097
  constructor(ctx, env) {
2534
2098
  super(ctx, env);
2535
2099
  const envObj = env;
2536
2100
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
2537
- if (envObj?.[key]) this.envVars[key] = envObj[key];
2101
+ if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
2538
2102
  });
2103
+ this.containerTimeouts = this.getDefaultTimeouts(envObj);
2539
2104
  this.logger = createLogger({
2540
2105
  component: "sandbox-do",
2541
2106
  sandboxId: this.ctx.id.toString()
@@ -2548,16 +2113,24 @@ var Sandbox = class extends Container {
2548
2113
  this.codeInterpreter = new CodeInterpreter(this);
2549
2114
  this.ctx.blockConcurrencyWhile(async () => {
2550
2115
  this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
2116
+ this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
2551
2117
  this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
2552
2118
  const storedTokens = await this.ctx.storage.get("portTokens") || {};
2553
2119
  this.portTokens = /* @__PURE__ */ new Map();
2554
2120
  for (const [portStr, token] of Object.entries(storedTokens)) this.portTokens.set(parseInt(portStr, 10), token);
2121
+ const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
2122
+ if (storedTimeouts) this.containerTimeouts = {
2123
+ ...this.containerTimeouts,
2124
+ ...storedTimeouts
2125
+ };
2555
2126
  });
2556
2127
  }
2557
- async setSandboxName(name) {
2128
+ async setSandboxName(name, normalizeId) {
2558
2129
  if (!this.sandboxName) {
2559
2130
  this.sandboxName = name;
2131
+ this.normalizeId = normalizeId || false;
2560
2132
  await this.ctx.storage.put("sandboxName", name);
2133
+ await this.ctx.storage.put("normalizeId", this.normalizeId);
2561
2134
  }
2562
2135
  }
2563
2136
  async setBaseUrl(baseUrl) {
@@ -2586,10 +2159,183 @@ var Sandbox = class extends Container {
2586
2159
  }
2587
2160
  }
2588
2161
  /**
2162
+ * RPC method to configure container startup timeouts
2163
+ */
2164
+ async setContainerTimeouts(timeouts) {
2165
+ const validated = { ...this.containerTimeouts };
2166
+ if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
2167
+ if (timeouts.portReadyTimeoutMS !== void 0) validated.portReadyTimeoutMS = this.validateTimeout(timeouts.portReadyTimeoutMS, "portReadyTimeoutMS", 1e4, 6e5);
2168
+ if (timeouts.waitIntervalMS !== void 0) validated.waitIntervalMS = this.validateTimeout(timeouts.waitIntervalMS, "waitIntervalMS", 100, 5e3);
2169
+ this.containerTimeouts = validated;
2170
+ await this.ctx.storage.put("containerTimeouts", this.containerTimeouts);
2171
+ this.logger.debug("Container timeouts updated", this.containerTimeouts);
2172
+ }
2173
+ /**
2174
+ * Validate a timeout value is within acceptable range
2175
+ * Throws error if invalid - used for user-provided values
2176
+ */
2177
+ validateTimeout(value, name, min, max) {
2178
+ if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
2179
+ if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
2180
+ return value;
2181
+ }
2182
+ /**
2183
+ * Get default timeouts with env var fallbacks and validation
2184
+ * Precedence: SDK defaults < Env vars < User config
2185
+ */
2186
+ getDefaultTimeouts(env) {
2187
+ const parseAndValidate = (envVar, name, min, max) => {
2188
+ const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
2189
+ if (envVar === void 0) return defaultValue;
2190
+ const parsed = parseInt(envVar, 10);
2191
+ if (Number.isNaN(parsed)) {
2192
+ this.logger.warn(`Invalid ${name}: "${envVar}" is not a number. Using default: ${defaultValue}ms`);
2193
+ return defaultValue;
2194
+ }
2195
+ if (parsed < min || parsed > max) {
2196
+ this.logger.warn(`Invalid ${name}: ${parsed}ms. Must be ${min}-${max}ms. Using default: ${defaultValue}ms`);
2197
+ return defaultValue;
2198
+ }
2199
+ return parsed;
2200
+ };
2201
+ return {
2202
+ instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
2203
+ portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
2204
+ waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
2205
+ };
2206
+ }
2207
+ async mountBucket(bucket, mountPath, options) {
2208
+ this.logger.info(`Mounting bucket ${bucket} to ${mountPath}`);
2209
+ this.validateMountOptions(bucket, mountPath, options);
2210
+ const provider = options.provider || detectProviderFromUrl(options.endpoint);
2211
+ this.logger.debug(`Detected provider: ${provider || "unknown"}`, { explicitProvider: options.provider });
2212
+ const credentials = detectCredentials(options, this.envVars);
2213
+ const passwordFilePath = this.generatePasswordFilePath();
2214
+ this.activeMounts.set(mountPath, {
2215
+ bucket,
2216
+ mountPath,
2217
+ endpoint: options.endpoint,
2218
+ provider,
2219
+ passwordFilePath,
2220
+ mounted: false
2221
+ });
2222
+ try {
2223
+ await this.createPasswordFile(passwordFilePath, bucket, credentials);
2224
+ await this.exec(`mkdir -p ${shellEscape(mountPath)}`);
2225
+ await this.executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath);
2226
+ this.activeMounts.set(mountPath, {
2227
+ bucket,
2228
+ mountPath,
2229
+ endpoint: options.endpoint,
2230
+ provider,
2231
+ passwordFilePath,
2232
+ mounted: true
2233
+ });
2234
+ this.logger.info(`Successfully mounted bucket ${bucket} to ${mountPath}`);
2235
+ } catch (error) {
2236
+ await this.deletePasswordFile(passwordFilePath);
2237
+ this.activeMounts.delete(mountPath);
2238
+ throw error;
2239
+ }
2240
+ }
2241
+ /**
2242
+ * Manually unmount a bucket filesystem
2243
+ *
2244
+ * @param mountPath - Absolute path where the bucket is mounted
2245
+ * @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted
2246
+ */
2247
+ async unmountBucket(mountPath) {
2248
+ this.logger.info(`Unmounting bucket from ${mountPath}`);
2249
+ const mountInfo = this.activeMounts.get(mountPath);
2250
+ if (!mountInfo) throw new InvalidMountConfigError(`No active mount found at path: ${mountPath}`);
2251
+ try {
2252
+ await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
2253
+ mountInfo.mounted = false;
2254
+ this.activeMounts.delete(mountPath);
2255
+ } finally {
2256
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
2257
+ }
2258
+ this.logger.info(`Successfully unmounted bucket from ${mountPath}`);
2259
+ }
2260
+ /**
2261
+ * Validate mount options
2262
+ */
2263
+ validateMountOptions(bucket, mountPath, options) {
2264
+ if (!options.endpoint) throw new InvalidMountConfigError("Endpoint is required. Provide the full S3-compatible endpoint URL.");
2265
+ try {
2266
+ new URL(options.endpoint);
2267
+ } catch (error) {
2268
+ throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
2269
+ }
2270
+ if (!/^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/.test(bucket)) throw new InvalidMountConfigError(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.`);
2271
+ if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
2272
+ if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
2273
+ }
2274
+ /**
2275
+ * Generate unique password file path for s3fs credentials
2276
+ */
2277
+ generatePasswordFilePath() {
2278
+ return `/tmp/.passwd-s3fs-${crypto.randomUUID()}`;
2279
+ }
2280
+ /**
2281
+ * Create password file with s3fs credentials
2282
+ * Format: bucket:accessKeyId:secretAccessKey
2283
+ */
2284
+ async createPasswordFile(passwordFilePath, bucket, credentials) {
2285
+ const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
2286
+ await this.writeFile(passwordFilePath, content);
2287
+ await this.exec(`chmod 0600 ${shellEscape(passwordFilePath)}`);
2288
+ this.logger.debug(`Created password file: ${passwordFilePath}`);
2289
+ }
2290
+ /**
2291
+ * Delete password file
2292
+ */
2293
+ async deletePasswordFile(passwordFilePath) {
2294
+ try {
2295
+ await this.exec(`rm -f ${shellEscape(passwordFilePath)}`);
2296
+ this.logger.debug(`Deleted password file: ${passwordFilePath}`);
2297
+ } catch (error) {
2298
+ this.logger.warn(`Failed to delete password file ${passwordFilePath}`, { error: error instanceof Error ? error.message : String(error) });
2299
+ }
2300
+ }
2301
+ /**
2302
+ * Execute S3FS mount command
2303
+ */
2304
+ async executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath) {
2305
+ const resolvedOptions = resolveS3fsOptions(provider, options.s3fsOptions);
2306
+ const s3fsArgs = [];
2307
+ s3fsArgs.push(`passwd_file=${passwordFilePath}`);
2308
+ s3fsArgs.push(...resolvedOptions);
2309
+ if (options.readOnly) s3fsArgs.push("ro");
2310
+ s3fsArgs.push(`url=${options.endpoint}`);
2311
+ const optionsStr = shellEscape(s3fsArgs.join(","));
2312
+ const mountCmd = `s3fs ${shellEscape(bucket)} ${shellEscape(mountPath)} -o ${optionsStr}`;
2313
+ this.logger.debug("Executing s3fs mount", {
2314
+ bucket,
2315
+ mountPath,
2316
+ provider,
2317
+ resolvedOptions
2318
+ });
2319
+ const result = await this.exec(mountCmd);
2320
+ if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
2321
+ this.logger.debug("Mount command executed successfully");
2322
+ }
2323
+ /**
2589
2324
  * Cleanup and destroy the sandbox container
2590
2325
  */
2591
2326
  async destroy() {
2592
2327
  this.logger.info("Destroying sandbox container");
2328
+ for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
2329
+ if (mountInfo.mounted) try {
2330
+ this.logger.info(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
2331
+ await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
2332
+ mountInfo.mounted = false;
2333
+ } catch (error) {
2334
+ const errorMsg = error instanceof Error ? error.message : String(error);
2335
+ this.logger.warn(`Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`);
2336
+ }
2337
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
2338
+ }
2593
2339
  await super.destroy();
2594
2340
  }
2595
2341
  onStart() {
@@ -2621,13 +2367,75 @@ var Sandbox = class extends Container {
2621
2367
  this.logger.debug("Version compatibility check encountered an error", { error: error instanceof Error ? error.message : String(error) });
2622
2368
  }
2623
2369
  }
2624
- onStop() {
2370
+ async onStop() {
2625
2371
  this.logger.debug("Sandbox stopped");
2372
+ this.portTokens.clear();
2373
+ this.defaultSession = null;
2374
+ this.activeMounts.clear();
2375
+ await Promise.all([this.ctx.storage.delete("portTokens"), this.ctx.storage.delete("defaultSession")]);
2626
2376
  }
2627
2377
  onError(error) {
2628
2378
  this.logger.error("Sandbox error", error instanceof Error ? error : new Error(String(error)));
2629
2379
  }
2630
2380
  /**
2381
+ * Override Container.containerFetch to use production-friendly timeouts
2382
+ * Automatically starts container with longer timeouts if not running
2383
+ */
2384
+ async containerFetch(requestOrUrl, portOrInit, portParam) {
2385
+ const { request, port } = this.parseContainerFetchArgs(requestOrUrl, portOrInit, portParam);
2386
+ if ((await this.getState()).status !== "healthy") try {
2387
+ this.logger.debug("Starting container with configured timeouts", {
2388
+ instanceTimeout: this.containerTimeouts.instanceGetTimeoutMS,
2389
+ portTimeout: this.containerTimeouts.portReadyTimeoutMS
2390
+ });
2391
+ await this.startAndWaitForPorts({
2392
+ ports: port,
2393
+ cancellationOptions: {
2394
+ instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS,
2395
+ portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS,
2396
+ waitInterval: this.containerTimeouts.waitIntervalMS,
2397
+ abort: request.signal
2398
+ }
2399
+ });
2400
+ } catch (e) {
2401
+ if (this.isNoInstanceError(e)) return new Response("Container is currently provisioning. This can take several minutes on first deployment. Please retry in a moment.", {
2402
+ status: 503,
2403
+ headers: { "Retry-After": "10" }
2404
+ });
2405
+ this.logger.error("Container startup failed", e instanceof Error ? e : new Error(String(e)));
2406
+ return new Response(`Failed to start container: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
2407
+ }
2408
+ return await super.containerFetch(requestOrUrl, portOrInit, portParam);
2409
+ }
2410
+ /**
2411
+ * Helper: Check if error is "no container instance available"
2412
+ */
2413
+ isNoInstanceError(error) {
2414
+ return error instanceof Error && error.message.toLowerCase().includes("no container instance");
2415
+ }
2416
+ /**
2417
+ * Helper: Parse containerFetch arguments (supports multiple signatures)
2418
+ */
2419
+ parseContainerFetchArgs(requestOrUrl, portOrInit, portParam) {
2420
+ let request;
2421
+ let port;
2422
+ if (requestOrUrl instanceof Request) {
2423
+ request = requestOrUrl;
2424
+ port = typeof portOrInit === "number" ? portOrInit : void 0;
2425
+ } else {
2426
+ const url = typeof requestOrUrl === "string" ? requestOrUrl : requestOrUrl.toString();
2427
+ const init = typeof portOrInit === "number" ? {} : portOrInit || {};
2428
+ port = typeof portOrInit === "number" ? portOrInit : typeof portParam === "number" ? portParam : void 0;
2429
+ request = new Request(url, init);
2430
+ }
2431
+ port ??= this.defaultPort;
2432
+ if (port === void 0) throw new Error("No port specified for container fetch");
2433
+ return {
2434
+ request,
2435
+ port
2436
+ };
2437
+ }
2438
+ /**
2631
2439
  * Override onActivityExpired to prevent automatic shutdown when keepAlive is enabled
2632
2440
  * When keepAlive is disabled, calls parent implementation which stops the container
2633
2441
  */
@@ -2644,28 +2452,26 @@ var Sandbox = class extends Container {
2644
2452
  traceId,
2645
2453
  operation: "fetch"
2646
2454
  });
2647
- return await runWithLogger(requestLogger, async () => {
2648
- const url = new URL(request.url);
2649
- if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
2650
- const name = request.headers.get("X-Sandbox-Name");
2651
- this.sandboxName = name;
2652
- await this.ctx.storage.put("sandboxName", name);
2653
- }
2654
- const upgradeHeader = request.headers.get("Upgrade");
2655
- const connectionHeader = request.headers.get("Connection");
2656
- if (upgradeHeader?.toLowerCase() === "websocket" && connectionHeader?.toLowerCase().includes("upgrade")) try {
2657
- requestLogger.debug("WebSocket upgrade requested", {
2658
- path: url.pathname,
2659
- port: this.determinePort(url)
2660
- });
2661
- return await super.fetch(request);
2662
- } catch (error) {
2663
- requestLogger.error("WebSocket connection failed", error instanceof Error ? error : new Error(String(error)), { path: url.pathname });
2664
- throw error;
2665
- }
2666
- const port = this.determinePort(url);
2667
- return await this.containerFetch(request, port);
2668
- });
2455
+ const url = new URL(request.url);
2456
+ if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
2457
+ const name = request.headers.get("X-Sandbox-Name");
2458
+ this.sandboxName = name;
2459
+ await this.ctx.storage.put("sandboxName", name);
2460
+ }
2461
+ const upgradeHeader = request.headers.get("Upgrade");
2462
+ const connectionHeader = request.headers.get("Connection");
2463
+ if (upgradeHeader?.toLowerCase() === "websocket" && connectionHeader?.toLowerCase().includes("upgrade")) try {
2464
+ requestLogger.debug("WebSocket upgrade requested", {
2465
+ path: url.pathname,
2466
+ port: this.determinePort(url)
2467
+ });
2468
+ return await super.fetch(request);
2469
+ } catch (error) {
2470
+ requestLogger.error("WebSocket connection failed", error instanceof Error ? error : new Error(String(error)), { path: url.pathname });
2471
+ throw error;
2472
+ }
2473
+ const port = this.determinePort(url);
2474
+ return await this.containerFetch(request, port);
2669
2475
  }
2670
2476
  wsConnect(request, port) {
2671
2477
  throw new Error("Not implemented here to avoid RPC serialization issues");
@@ -2696,7 +2502,7 @@ var Sandbox = class extends Container {
2696
2502
  await this.ctx.storage.put("defaultSession", sessionId);
2697
2503
  this.logger.debug("Default session initialized", { sessionId });
2698
2504
  } catch (error) {
2699
- if (error?.message?.includes("already exists")) {
2505
+ if (error instanceof Error && error.message.includes("already exists")) {
2700
2506
  this.logger.debug("Reusing existing session after reload", { sessionId });
2701
2507
  this.defaultSession = sessionId;
2702
2508
  await this.ctx.storage.put("defaultSession", sessionId);
@@ -3017,7 +2823,10 @@ var Sandbox = class extends Container {
3017
2823
  }
3018
2824
  constructPreviewUrl(port, sandboxId, hostname, token) {
3019
2825
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3020
- const sanitizedSandboxId = sanitizeSandboxId(sandboxId);
2826
+ const effectiveId = this.sandboxName || sandboxId;
2827
+ const hasUppercase = /[A-Z]/.test(effectiveId);
2828
+ if (!this.normalizeId && hasUppercase) throw new SecurityError(`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\nTo fix this:\n1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\nNote: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`);
2829
+ const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
3021
2830
  if (isLocalhostPattern(hostname)) {
3022
2831
  const [host, portStr] = hostname.split(":");
3023
2832
  const mainPort = portStr || "80";
@@ -3139,7 +2948,9 @@ var Sandbox = class extends Container {
3139
2948
  },
3140
2949
  runCodeStream: (code, options) => this.codeInterpreter.runCodeStream(code, options),
3141
2950
  listCodeContexts: () => this.codeInterpreter.listCodeContexts(),
3142
- deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId)
2951
+ deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId),
2952
+ mountBucket: (bucket, mountPath, options) => this.mountBucket(bucket, mountPath, options),
2953
+ unmountBucket: (mountPath) => this.unmountBucket(mountPath)
3143
2954
  };
3144
2955
  }
3145
2956
  async createCodeContext(options) {
@@ -3276,5 +3087,5 @@ async function collectFile(stream) {
3276
3087
  }
3277
3088
 
3278
3089
  //#endregion
3279
- export { CodeInterpreter, CommandClient, Execution, FileClient, GitClient, GitLogger, LogLevel as LogLevelEnum, PortClient, ProcessClient, ResultImpl, Sandbox, SandboxClient, TraceContext, UtilityClient, asyncIterableToSSEStream, collectFile, createLogger, createNoOpLogger, getLogger, getSandbox, isExecResult, isProcess, isProcessStatus, parseSSEStream, proxyToSandbox, redactCredentials, responseToAsyncIterable, runWithLogger, sanitizeGitData, streamFile };
3090
+ export { BucketMountError, CodeInterpreter, CommandClient, FileClient, GitClient, InvalidMountConfigError, MissingCredentialsError, PortClient, ProcessClient, S3FSMountError, Sandbox, SandboxClient, UtilityClient, asyncIterableToSSEStream, collectFile, getSandbox, isExecResult, isProcess, isProcessStatus, parseSSEStream, proxyToSandbox, responseToAsyncIterable, streamFile };
3280
3091
  //# sourceMappingURL=index.js.map