@cloudflare/sandbox 0.4.18 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
3
3
 
4
+ //#region ../shared/dist/env.js
5
+ /**
6
+ * Safely extract a string value from an environment object
7
+ *
8
+ * @param env - Environment object with dynamic keys
9
+ * @param key - The environment variable key to access
10
+ * @returns The string value if present and is a string, undefined otherwise
11
+ */
12
+ function getEnvString(env, key) {
13
+ const value = env?.[key];
14
+ return typeof value === "string" ? value : void 0;
15
+ }
16
+
17
+ //#endregion
4
18
  //#region ../shared/dist/git.js
5
19
  /**
6
20
  * Redact credentials from URLs for secure logging
@@ -225,6 +239,9 @@ var LogLevel;
225
239
  //#endregion
226
240
  //#region ../shared/dist/logger/logger.js
227
241
  /**
242
+ * Logger implementation
243
+ */
244
+ /**
228
245
  * ANSI color codes for terminal output
229
246
  */
230
247
  const COLORS = {
@@ -344,7 +361,7 @@ var CloudflareLogger = class CloudflareLogger {
344
361
  * Example: INFO [sandbox-do] Command started (trace: tr_7f3a9b2c) {commandId: "cmd-123"}
345
362
  */
346
363
  outputPretty(consoleFn, data) {
347
- const { level, msg, timestamp, traceId, component, sandboxId, sessionId, processId, commandId, operation, duration, error,...rest } = data;
364
+ const { level, msg, timestamp, traceId, component, sandboxId, sessionId, processId, commandId, operation, duration, error, ...rest } = data;
348
365
  const levelStr = String(level || "INFO").toUpperCase();
349
366
  const levelColor = this.getLevelColor(levelStr);
350
367
  const componentBadge = component ? `[${component}]` : "";
@@ -441,6 +458,39 @@ var TraceContext = class TraceContext {
441
458
  //#endregion
442
459
  //#region ../shared/dist/logger/index.js
443
460
  /**
461
+ * Logger module
462
+ *
463
+ * Provides structured, trace-aware logging with:
464
+ * - AsyncLocalStorage for implicit context propagation
465
+ * - Pretty printing for local development
466
+ * - JSON output for production
467
+ * - Environment auto-detection
468
+ * - Log level configuration
469
+ *
470
+ * Usage:
471
+ *
472
+ * ```typescript
473
+ * // Create a logger at entry point
474
+ * const logger = createLogger({ component: 'sandbox-do', traceId: 'tr_abc123' });
475
+ *
476
+ * // Store in AsyncLocalStorage for entire request
477
+ * await runWithLogger(logger, async () => {
478
+ * await handleRequest();
479
+ * });
480
+ *
481
+ * // Retrieve logger anywhere in call stack
482
+ * const logger = getLogger();
483
+ * logger.info('Operation started');
484
+ *
485
+ * // Add operation-specific context
486
+ * const execLogger = logger.child({ operation: 'exec', commandId: 'cmd-456' });
487
+ * await runWithLogger(execLogger, async () => {
488
+ * // All nested calls automatically get execLogger
489
+ * await executeCommand();
490
+ * });
491
+ * ```
492
+ */
493
+ /**
444
494
  * Create a no-op logger for testing
445
495
  *
446
496
  * Returns a logger that implements the Logger interface but does nothing.
@@ -474,25 +524,6 @@ function createNoOpLogger() {
474
524
  */
475
525
  const loggerStorage = new AsyncLocalStorage();
476
526
  /**
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
527
  * Run a function with a logger stored in AsyncLocalStorage
497
528
  *
498
529
  * The logger is available to all code within the function via getLogger().
@@ -600,6 +631,17 @@ function getEnvVar(name) {
600
631
  }
601
632
  }
602
633
 
634
+ //#endregion
635
+ //#region ../shared/dist/shell-escape.js
636
+ /**
637
+ * Escapes a string for safe use in shell commands using POSIX single-quote escaping.
638
+ * Prevents command injection by wrapping the string in single quotes and escaping
639
+ * any single quotes within the string.
640
+ */
641
+ function shellEscape(str) {
642
+ return `'${str.replace(/'/g, "'\\''")}'`;
643
+ }
644
+
603
645
  //#endregion
604
646
  //#region ../shared/dist/types.js
605
647
  function isExecResult(value) {
@@ -662,6 +704,10 @@ const ErrorCode = {
662
704
  GIT_CLONE_FAILED: "GIT_CLONE_FAILED",
663
705
  GIT_CHECKOUT_FAILED: "GIT_CHECKOUT_FAILED",
664
706
  GIT_OPERATION_FAILED: "GIT_OPERATION_FAILED",
707
+ BUCKET_MOUNT_ERROR: "BUCKET_MOUNT_ERROR",
708
+ S3FS_MOUNT_ERROR: "S3FS_MOUNT_ERROR",
709
+ MISSING_CREDENTIALS: "MISSING_CREDENTIALS",
710
+ INVALID_MOUNT_CONFIG: "INVALID_MOUNT_CONFIG",
665
711
  INTERPRETER_NOT_READY: "INTERPRETER_NOT_READY",
666
712
  CONTEXT_NOT_FOUND: "CONTEXT_NOT_FOUND",
667
713
  CODE_EXECUTION_ERROR: "CODE_EXECUTION_ERROR",
@@ -695,6 +741,8 @@ const ERROR_STATUS_MAP = {
695
741
  [ErrorCode.INVALID_JSON_RESPONSE]: 400,
696
742
  [ErrorCode.NAME_TOO_LONG]: 400,
697
743
  [ErrorCode.VALIDATION_FAILED]: 400,
744
+ [ErrorCode.MISSING_CREDENTIALS]: 400,
745
+ [ErrorCode.INVALID_MOUNT_CONFIG]: 400,
698
746
  [ErrorCode.GIT_AUTH_FAILED]: 401,
699
747
  [ErrorCode.PERMISSION_DENIED]: 403,
700
748
  [ErrorCode.COMMAND_PERMISSION_DENIED]: 403,
@@ -719,6 +767,8 @@ const ERROR_STATUS_MAP = {
719
767
  [ErrorCode.GIT_CHECKOUT_FAILED]: 500,
720
768
  [ErrorCode.GIT_OPERATION_FAILED]: 500,
721
769
  [ErrorCode.CODE_EXECUTION_ERROR]: 500,
770
+ [ErrorCode.BUCKET_MOUNT_ERROR]: 500,
771
+ [ErrorCode.S3FS_MOUNT_ERROR]: 500,
722
772
  [ErrorCode.UNKNOWN_ERROR]: 500,
723
773
  [ErrorCode.INTERNAL_ERROR]: 500
724
774
  };
@@ -1237,8 +1287,8 @@ function createErrorFromResponse(errorResponse) {
1237
1287
 
1238
1288
  //#endregion
1239
1289
  //#region src/clients/base-client.ts
1240
- const TIMEOUT_MS = 6e4;
1241
- const MIN_TIME_FOR_RETRY_MS = 1e4;
1290
+ const TIMEOUT_MS = 12e4;
1291
+ const MIN_TIME_FOR_RETRY_MS = 15e3;
1242
1292
  /**
1243
1293
  * Abstract base class providing common HTTP functionality for all domain clients
1244
1294
  */
@@ -1252,31 +1302,31 @@ var BaseHttpClient = class {
1252
1302
  this.baseUrl = this.options.baseUrl;
1253
1303
  }
1254
1304
  /**
1255
- * Core HTTP request method with automatic retry for container provisioning delays
1305
+ * Core HTTP request method with automatic retry for container startup delays
1306
+ * Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related
1256
1307
  */
1257
1308
  async doFetch(path, options) {
1258
1309
  const startTime = Date.now();
1259
1310
  let attempt = 0;
1260
1311
  while (true) {
1261
1312
  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
- }
1313
+ if (await this.isRetryableContainerError(response)) {
1314
+ const elapsed = Date.now() - startTime;
1315
+ const remaining = TIMEOUT_MS - elapsed;
1316
+ if (remaining > MIN_TIME_FOR_RETRY_MS) {
1317
+ const delay = Math.min(3e3 * 2 ** attempt, 3e4);
1318
+ this.logger.info("Container not ready, retrying", {
1319
+ status: response.status,
1320
+ attempt: attempt + 1,
1321
+ delayMs: delay,
1322
+ remainingSec: Math.floor(remaining / 1e3)
1323
+ });
1324
+ await new Promise((resolve) => setTimeout(resolve, delay));
1325
+ attempt++;
1326
+ continue;
1279
1327
  }
1328
+ this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1e3)}s`));
1329
+ return response;
1280
1330
  }
1281
1331
  return response;
1282
1332
  }
@@ -1372,14 +1422,50 @@ var BaseHttpClient = class {
1372
1422
  } else this.logger.error(`Error in ${operation}`, error instanceof Error ? error : new Error(String(error)));
1373
1423
  }
1374
1424
  /**
1375
- * Check if 503 response is from container provisioning (retryable)
1376
- * vs user application (not retryable)
1425
+ * Check if response indicates a retryable container error
1426
+ * Uses fail-safe strategy: only retry known transient errors
1427
+ *
1428
+ * TODO: This relies on string matching error messages, which is brittle.
1429
+ * Ideally, the container API should return structured errors with a
1430
+ * `retryable: boolean` field to avoid coupling to error message format.
1431
+ *
1432
+ * @param response - HTTP response to check
1433
+ * @returns true if error is retryable container error, false otherwise
1377
1434
  */
1378
- async isContainerProvisioningError(response) {
1435
+ async isRetryableContainerError(response) {
1436
+ if (response.status !== 500 && response.status !== 503) return false;
1379
1437
  try {
1380
- return (await response.clone().text()).includes("There is no Container instance available");
1438
+ const text = await response.clone().text();
1439
+ const textLower = text.toLowerCase();
1440
+ if ([
1441
+ "no such image",
1442
+ "container already exists",
1443
+ "malformed containerinspect"
1444
+ ].some((err) => textLower.includes(err))) {
1445
+ this.logger.debug("Detected permanent error, not retrying", { text });
1446
+ return false;
1447
+ }
1448
+ const shouldRetry = [
1449
+ "no container instance available",
1450
+ "currently provisioning",
1451
+ "container port not found",
1452
+ "connection refused: container port",
1453
+ "the container is not listening",
1454
+ "failed to verify port",
1455
+ "container did not start",
1456
+ "network connection lost",
1457
+ "container suddenly disconnected",
1458
+ "monitor failed to find container",
1459
+ "timed out",
1460
+ "timeout"
1461
+ ].some((err) => textLower.includes(err));
1462
+ if (!shouldRetry) this.logger.debug("Unknown error pattern, not retrying", {
1463
+ status: response.status,
1464
+ text: text.substring(0, 200)
1465
+ });
1466
+ return shouldRetry;
1381
1467
  } catch (error) {
1382
- this.logger.error("Error checking response body", error instanceof Error ? error : new Error(String(error)));
1468
+ this.logger.error("Error checking if response is retryable", error instanceof Error ? error : new Error(String(error)));
1383
1469
  return false;
1384
1470
  }
1385
1471
  }
@@ -2333,7 +2419,7 @@ async function proxyToSandbox(request, env) {
2333
2419
  const routeInfo = extractSandboxRoute(url);
2334
2420
  if (!routeInfo) return null;
2335
2421
  const { sandboxId, port, path, token } = routeInfo;
2336
- const sandbox = getSandbox(env.Sandbox, sandboxId);
2422
+ const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
2337
2423
  if (port !== 3e3) {
2338
2424
  if (!await sandbox.validatePortToken(port, token)) {
2339
2425
  logger.warn("Invalid token access blocked", {
@@ -2492,6 +2578,124 @@ function asyncIterableToSSEStream(events, options) {
2492
2578
  });
2493
2579
  }
2494
2580
 
2581
+ //#endregion
2582
+ //#region src/storage-mount/errors.ts
2583
+ /**
2584
+ * Bucket mounting error classes
2585
+ *
2586
+ * These are SDK-side validation errors that follow the same pattern as SecurityError.
2587
+ * They are thrown before any container interaction occurs.
2588
+ */
2589
+ /**
2590
+ * Base error for bucket mounting operations
2591
+ */
2592
+ var BucketMountError = class extends Error {
2593
+ code;
2594
+ constructor(message, code = ErrorCode.BUCKET_MOUNT_ERROR) {
2595
+ super(message);
2596
+ this.name = "BucketMountError";
2597
+ this.code = code;
2598
+ }
2599
+ };
2600
+ /**
2601
+ * Thrown when S3FS mount command fails
2602
+ */
2603
+ var S3FSMountError = class extends BucketMountError {
2604
+ constructor(message) {
2605
+ super(message, ErrorCode.S3FS_MOUNT_ERROR);
2606
+ this.name = "S3FSMountError";
2607
+ }
2608
+ };
2609
+ /**
2610
+ * Thrown when no credentials found in environment
2611
+ */
2612
+ var MissingCredentialsError = class extends BucketMountError {
2613
+ constructor(message) {
2614
+ super(message, ErrorCode.MISSING_CREDENTIALS);
2615
+ this.name = "MissingCredentialsError";
2616
+ }
2617
+ };
2618
+ /**
2619
+ * Thrown when bucket name, mount path, or options are invalid
2620
+ */
2621
+ var InvalidMountConfigError = class extends BucketMountError {
2622
+ constructor(message) {
2623
+ super(message, ErrorCode.INVALID_MOUNT_CONFIG);
2624
+ this.name = "InvalidMountConfigError";
2625
+ }
2626
+ };
2627
+
2628
+ //#endregion
2629
+ //#region src/storage-mount/credential-detection.ts
2630
+ /**
2631
+ * Detect credentials for bucket mounting from environment variables
2632
+ * Priority order:
2633
+ * 1. Explicit options.credentials
2634
+ * 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
2635
+ * 3. Error: no credentials found
2636
+ *
2637
+ * @param options - Mount options
2638
+ * @param envVars - Environment variables
2639
+ * @returns Detected credentials
2640
+ * @throws MissingCredentialsError if no credentials found
2641
+ */
2642
+ function detectCredentials(options, envVars) {
2643
+ if (options.credentials) return options.credentials;
2644
+ const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
2645
+ const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
2646
+ if (awsAccessKeyId && awsSecretAccessKey) return {
2647
+ accessKeyId: awsAccessKeyId,
2648
+ secretAccessKey: awsSecretAccessKey
2649
+ };
2650
+ throw new MissingCredentialsError("No credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or pass explicit credentials in options.");
2651
+ }
2652
+
2653
+ //#endregion
2654
+ //#region src/storage-mount/provider-detection.ts
2655
+ /**
2656
+ * Detect provider from endpoint URL using pattern matching
2657
+ */
2658
+ function detectProviderFromUrl(endpoint) {
2659
+ try {
2660
+ const hostname = new URL(endpoint).hostname.toLowerCase();
2661
+ if (hostname.endsWith(".r2.cloudflarestorage.com")) return "r2";
2662
+ if (hostname.endsWith(".amazonaws.com") || hostname === "s3.amazonaws.com") return "s3";
2663
+ if (hostname === "storage.googleapis.com") return "gcs";
2664
+ return null;
2665
+ } catch {
2666
+ return null;
2667
+ }
2668
+ }
2669
+ /**
2670
+ * Get s3fs flags for a given provider
2671
+ *
2672
+ * Based on s3fs-fuse wiki recommendations:
2673
+ * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
2674
+ */
2675
+ function getProviderFlags(provider) {
2676
+ if (!provider) return ["use_path_request_style"];
2677
+ switch (provider) {
2678
+ case "r2": return ["nomixupload"];
2679
+ case "s3": return [];
2680
+ case "gcs": return [];
2681
+ default: return ["use_path_request_style"];
2682
+ }
2683
+ }
2684
+ /**
2685
+ * Resolve s3fs options by combining provider defaults with user overrides
2686
+ */
2687
+ function resolveS3fsOptions(provider, userOptions) {
2688
+ const providerFlags = getProviderFlags(provider);
2689
+ if (!userOptions || userOptions.length === 0) return providerFlags;
2690
+ const allFlags = [...providerFlags, ...userOptions];
2691
+ const flagMap = /* @__PURE__ */ new Map();
2692
+ for (const flag of allFlags) {
2693
+ const [flagName] = flag.split("=");
2694
+ flagMap.set(flagName, flag);
2695
+ }
2696
+ return Array.from(flagMap.values());
2697
+ }
2698
+
2495
2699
  //#endregion
2496
2700
  //#region src/version.ts
2497
2701
  /**
@@ -2499,16 +2703,21 @@ function asyncIterableToSSEStream(events, options) {
2499
2703
  * This file is auto-updated by .github/changeset-version.ts during releases
2500
2704
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
2501
2705
  */
2502
- const SDK_VERSION = "0.4.18";
2706
+ const SDK_VERSION = "0.5.1";
2503
2707
 
2504
2708
  //#endregion
2505
2709
  //#region src/sandbox.ts
2506
2710
  function getSandbox(ns, id, options) {
2507
- const stub = getContainer(ns, id);
2508
- stub.setSandboxName?.(id);
2711
+ const sanitizedId = sanitizeSandboxId(id);
2712
+ const effectiveId = options?.normalizeId ? sanitizedId.toLowerCase() : sanitizedId;
2713
+ const hasUppercase = /[A-Z]/.test(sanitizedId);
2714
+ 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.`);
2715
+ const stub = getContainer(ns, effectiveId);
2716
+ stub.setSandboxName?.(effectiveId, options?.normalizeId);
2509
2717
  if (options?.baseUrl) stub.setBaseUrl(options.baseUrl);
2510
2718
  if (options?.sleepAfter !== void 0) stub.setSleepAfter(options.sleepAfter);
2511
2719
  if (options?.keepAlive !== void 0) stub.setKeepAlive(options.keepAlive);
2720
+ if (options?.containerTimeouts) stub.setContainerTimeouts(options.containerTimeouts);
2512
2721
  return Object.assign(stub, { wsConnect: connect(stub) });
2513
2722
  }
2514
2723
  function connect(stub) {
@@ -2524,18 +2733,35 @@ var Sandbox = class extends Container {
2524
2733
  client;
2525
2734
  codeInterpreter;
2526
2735
  sandboxName = null;
2736
+ normalizeId = false;
2527
2737
  baseUrl = null;
2528
2738
  portTokens = /* @__PURE__ */ new Map();
2529
2739
  defaultSession = null;
2530
2740
  envVars = {};
2531
2741
  logger;
2532
2742
  keepAliveEnabled = false;
2743
+ activeMounts = /* @__PURE__ */ new Map();
2744
+ /**
2745
+ * Default container startup timeouts (conservative for production)
2746
+ * Based on Cloudflare docs: "Containers take several minutes to provision"
2747
+ */
2748
+ DEFAULT_CONTAINER_TIMEOUTS = {
2749
+ instanceGetTimeoutMS: 3e4,
2750
+ portReadyTimeoutMS: 9e4,
2751
+ waitIntervalMS: 1e3
2752
+ };
2753
+ /**
2754
+ * Active container timeout configuration
2755
+ * Can be set via options, env vars, or defaults
2756
+ */
2757
+ containerTimeouts = { ...this.DEFAULT_CONTAINER_TIMEOUTS };
2533
2758
  constructor(ctx, env) {
2534
2759
  super(ctx, env);
2535
2760
  const envObj = env;
2536
2761
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
2537
- if (envObj?.[key]) this.envVars[key] = envObj[key];
2762
+ if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
2538
2763
  });
2764
+ this.containerTimeouts = this.getDefaultTimeouts(envObj);
2539
2765
  this.logger = createLogger({
2540
2766
  component: "sandbox-do",
2541
2767
  sandboxId: this.ctx.id.toString()
@@ -2548,16 +2774,24 @@ var Sandbox = class extends Container {
2548
2774
  this.codeInterpreter = new CodeInterpreter(this);
2549
2775
  this.ctx.blockConcurrencyWhile(async () => {
2550
2776
  this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
2777
+ this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
2551
2778
  this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
2552
2779
  const storedTokens = await this.ctx.storage.get("portTokens") || {};
2553
2780
  this.portTokens = /* @__PURE__ */ new Map();
2554
2781
  for (const [portStr, token] of Object.entries(storedTokens)) this.portTokens.set(parseInt(portStr, 10), token);
2782
+ const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
2783
+ if (storedTimeouts) this.containerTimeouts = {
2784
+ ...this.containerTimeouts,
2785
+ ...storedTimeouts
2786
+ };
2555
2787
  });
2556
2788
  }
2557
- async setSandboxName(name) {
2789
+ async setSandboxName(name, normalizeId) {
2558
2790
  if (!this.sandboxName) {
2559
2791
  this.sandboxName = name;
2792
+ this.normalizeId = normalizeId || false;
2560
2793
  await this.ctx.storage.put("sandboxName", name);
2794
+ await this.ctx.storage.put("normalizeId", this.normalizeId);
2561
2795
  }
2562
2796
  }
2563
2797
  async setBaseUrl(baseUrl) {
@@ -2586,10 +2820,183 @@ var Sandbox = class extends Container {
2586
2820
  }
2587
2821
  }
2588
2822
  /**
2823
+ * RPC method to configure container startup timeouts
2824
+ */
2825
+ async setContainerTimeouts(timeouts) {
2826
+ const validated = { ...this.containerTimeouts };
2827
+ if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
2828
+ if (timeouts.portReadyTimeoutMS !== void 0) validated.portReadyTimeoutMS = this.validateTimeout(timeouts.portReadyTimeoutMS, "portReadyTimeoutMS", 1e4, 6e5);
2829
+ if (timeouts.waitIntervalMS !== void 0) validated.waitIntervalMS = this.validateTimeout(timeouts.waitIntervalMS, "waitIntervalMS", 100, 5e3);
2830
+ this.containerTimeouts = validated;
2831
+ await this.ctx.storage.put("containerTimeouts", this.containerTimeouts);
2832
+ this.logger.debug("Container timeouts updated", this.containerTimeouts);
2833
+ }
2834
+ /**
2835
+ * Validate a timeout value is within acceptable range
2836
+ * Throws error if invalid - used for user-provided values
2837
+ */
2838
+ validateTimeout(value, name, min, max) {
2839
+ if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
2840
+ if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
2841
+ return value;
2842
+ }
2843
+ /**
2844
+ * Get default timeouts with env var fallbacks and validation
2845
+ * Precedence: SDK defaults < Env vars < User config
2846
+ */
2847
+ getDefaultTimeouts(env) {
2848
+ const parseAndValidate = (envVar, name, min, max) => {
2849
+ const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
2850
+ if (envVar === void 0) return defaultValue;
2851
+ const parsed = parseInt(envVar, 10);
2852
+ if (Number.isNaN(parsed)) {
2853
+ this.logger.warn(`Invalid ${name}: "${envVar}" is not a number. Using default: ${defaultValue}ms`);
2854
+ return defaultValue;
2855
+ }
2856
+ if (parsed < min || parsed > max) {
2857
+ this.logger.warn(`Invalid ${name}: ${parsed}ms. Must be ${min}-${max}ms. Using default: ${defaultValue}ms`);
2858
+ return defaultValue;
2859
+ }
2860
+ return parsed;
2861
+ };
2862
+ return {
2863
+ instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
2864
+ portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
2865
+ waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
2866
+ };
2867
+ }
2868
+ async mountBucket(bucket, mountPath, options) {
2869
+ this.logger.info(`Mounting bucket ${bucket} to ${mountPath}`);
2870
+ this.validateMountOptions(bucket, mountPath, options);
2871
+ const provider = options.provider || detectProviderFromUrl(options.endpoint);
2872
+ this.logger.debug(`Detected provider: ${provider || "unknown"}`, { explicitProvider: options.provider });
2873
+ const credentials = detectCredentials(options, this.envVars);
2874
+ const passwordFilePath = this.generatePasswordFilePath();
2875
+ this.activeMounts.set(mountPath, {
2876
+ bucket,
2877
+ mountPath,
2878
+ endpoint: options.endpoint,
2879
+ provider,
2880
+ passwordFilePath,
2881
+ mounted: false
2882
+ });
2883
+ try {
2884
+ await this.createPasswordFile(passwordFilePath, bucket, credentials);
2885
+ await this.exec(`mkdir -p ${shellEscape(mountPath)}`);
2886
+ await this.executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath);
2887
+ this.activeMounts.set(mountPath, {
2888
+ bucket,
2889
+ mountPath,
2890
+ endpoint: options.endpoint,
2891
+ provider,
2892
+ passwordFilePath,
2893
+ mounted: true
2894
+ });
2895
+ this.logger.info(`Successfully mounted bucket ${bucket} to ${mountPath}`);
2896
+ } catch (error) {
2897
+ await this.deletePasswordFile(passwordFilePath);
2898
+ this.activeMounts.delete(mountPath);
2899
+ throw error;
2900
+ }
2901
+ }
2902
+ /**
2903
+ * Manually unmount a bucket filesystem
2904
+ *
2905
+ * @param mountPath - Absolute path where the bucket is mounted
2906
+ * @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted
2907
+ */
2908
+ async unmountBucket(mountPath) {
2909
+ this.logger.info(`Unmounting bucket from ${mountPath}`);
2910
+ const mountInfo = this.activeMounts.get(mountPath);
2911
+ if (!mountInfo) throw new InvalidMountConfigError(`No active mount found at path: ${mountPath}`);
2912
+ try {
2913
+ await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
2914
+ mountInfo.mounted = false;
2915
+ this.activeMounts.delete(mountPath);
2916
+ } finally {
2917
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
2918
+ }
2919
+ this.logger.info(`Successfully unmounted bucket from ${mountPath}`);
2920
+ }
2921
+ /**
2922
+ * Validate mount options
2923
+ */
2924
+ validateMountOptions(bucket, mountPath, options) {
2925
+ if (!options.endpoint) throw new InvalidMountConfigError("Endpoint is required. Provide the full S3-compatible endpoint URL.");
2926
+ try {
2927
+ new URL(options.endpoint);
2928
+ } catch (error) {
2929
+ throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
2930
+ }
2931
+ 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.`);
2932
+ if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
2933
+ 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.`);
2934
+ }
2935
+ /**
2936
+ * Generate unique password file path for s3fs credentials
2937
+ */
2938
+ generatePasswordFilePath() {
2939
+ return `/tmp/.passwd-s3fs-${crypto.randomUUID()}`;
2940
+ }
2941
+ /**
2942
+ * Create password file with s3fs credentials
2943
+ * Format: bucket:accessKeyId:secretAccessKey
2944
+ */
2945
+ async createPasswordFile(passwordFilePath, bucket, credentials) {
2946
+ const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
2947
+ await this.writeFile(passwordFilePath, content);
2948
+ await this.exec(`chmod 0600 ${shellEscape(passwordFilePath)}`);
2949
+ this.logger.debug(`Created password file: ${passwordFilePath}`);
2950
+ }
2951
+ /**
2952
+ * Delete password file
2953
+ */
2954
+ async deletePasswordFile(passwordFilePath) {
2955
+ try {
2956
+ await this.exec(`rm -f ${shellEscape(passwordFilePath)}`);
2957
+ this.logger.debug(`Deleted password file: ${passwordFilePath}`);
2958
+ } catch (error) {
2959
+ this.logger.warn(`Failed to delete password file ${passwordFilePath}`, { error: error instanceof Error ? error.message : String(error) });
2960
+ }
2961
+ }
2962
+ /**
2963
+ * Execute S3FS mount command
2964
+ */
2965
+ async executeS3FSMount(bucket, mountPath, options, provider, passwordFilePath) {
2966
+ const resolvedOptions = resolveS3fsOptions(provider, options.s3fsOptions);
2967
+ const s3fsArgs = [];
2968
+ s3fsArgs.push(`passwd_file=${passwordFilePath}`);
2969
+ s3fsArgs.push(...resolvedOptions);
2970
+ if (options.readOnly) s3fsArgs.push("ro");
2971
+ s3fsArgs.push(`url=${options.endpoint}`);
2972
+ const optionsStr = shellEscape(s3fsArgs.join(","));
2973
+ const mountCmd = `s3fs ${shellEscape(bucket)} ${shellEscape(mountPath)} -o ${optionsStr}`;
2974
+ this.logger.debug("Executing s3fs mount", {
2975
+ bucket,
2976
+ mountPath,
2977
+ provider,
2978
+ resolvedOptions
2979
+ });
2980
+ const result = await this.exec(mountCmd);
2981
+ if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
2982
+ this.logger.debug("Mount command executed successfully");
2983
+ }
2984
+ /**
2589
2985
  * Cleanup and destroy the sandbox container
2590
2986
  */
2591
2987
  async destroy() {
2592
2988
  this.logger.info("Destroying sandbox container");
2989
+ for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
2990
+ if (mountInfo.mounted) try {
2991
+ this.logger.info(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
2992
+ await this.exec(`fusermount -u ${shellEscape(mountPath)}`);
2993
+ mountInfo.mounted = false;
2994
+ } catch (error) {
2995
+ const errorMsg = error instanceof Error ? error.message : String(error);
2996
+ this.logger.warn(`Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`);
2997
+ }
2998
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
2999
+ }
2593
3000
  await super.destroy();
2594
3001
  }
2595
3002
  onStart() {
@@ -2628,6 +3035,64 @@ var Sandbox = class extends Container {
2628
3035
  this.logger.error("Sandbox error", error instanceof Error ? error : new Error(String(error)));
2629
3036
  }
2630
3037
  /**
3038
+ * Override Container.containerFetch to use production-friendly timeouts
3039
+ * Automatically starts container with longer timeouts if not running
3040
+ */
3041
+ async containerFetch(requestOrUrl, portOrInit, portParam) {
3042
+ const { request, port } = this.parseContainerFetchArgs(requestOrUrl, portOrInit, portParam);
3043
+ if ((await this.getState()).status !== "healthy") try {
3044
+ this.logger.debug("Starting container with configured timeouts", {
3045
+ instanceTimeout: this.containerTimeouts.instanceGetTimeoutMS,
3046
+ portTimeout: this.containerTimeouts.portReadyTimeoutMS
3047
+ });
3048
+ await this.startAndWaitForPorts({
3049
+ ports: port,
3050
+ cancellationOptions: {
3051
+ instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS,
3052
+ portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS,
3053
+ waitInterval: this.containerTimeouts.waitIntervalMS,
3054
+ abort: request.signal
3055
+ }
3056
+ });
3057
+ } catch (e) {
3058
+ if (this.isNoInstanceError(e)) return new Response("Container is currently provisioning. This can take several minutes on first deployment. Please retry in a moment.", {
3059
+ status: 503,
3060
+ headers: { "Retry-After": "10" }
3061
+ });
3062
+ this.logger.error("Container startup failed", e instanceof Error ? e : new Error(String(e)));
3063
+ return new Response(`Failed to start container: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
3064
+ }
3065
+ return await super.containerFetch(requestOrUrl, portOrInit, portParam);
3066
+ }
3067
+ /**
3068
+ * Helper: Check if error is "no container instance available"
3069
+ */
3070
+ isNoInstanceError(error) {
3071
+ return error instanceof Error && error.message.toLowerCase().includes("no container instance");
3072
+ }
3073
+ /**
3074
+ * Helper: Parse containerFetch arguments (supports multiple signatures)
3075
+ */
3076
+ parseContainerFetchArgs(requestOrUrl, portOrInit, portParam) {
3077
+ let request;
3078
+ let port;
3079
+ if (requestOrUrl instanceof Request) {
3080
+ request = requestOrUrl;
3081
+ port = typeof portOrInit === "number" ? portOrInit : void 0;
3082
+ } else {
3083
+ const url = typeof requestOrUrl === "string" ? requestOrUrl : requestOrUrl.toString();
3084
+ const init = typeof portOrInit === "number" ? {} : portOrInit || {};
3085
+ port = typeof portOrInit === "number" ? portOrInit : typeof portParam === "number" ? portParam : void 0;
3086
+ request = new Request(url, init);
3087
+ }
3088
+ port ??= this.defaultPort;
3089
+ if (port === void 0) throw new Error("No port specified for container fetch");
3090
+ return {
3091
+ request,
3092
+ port
3093
+ };
3094
+ }
3095
+ /**
2631
3096
  * Override onActivityExpired to prevent automatic shutdown when keepAlive is enabled
2632
3097
  * When keepAlive is disabled, calls parent implementation which stops the container
2633
3098
  */
@@ -2696,7 +3161,7 @@ var Sandbox = class extends Container {
2696
3161
  await this.ctx.storage.put("defaultSession", sessionId);
2697
3162
  this.logger.debug("Default session initialized", { sessionId });
2698
3163
  } catch (error) {
2699
- if (error?.message?.includes("already exists")) {
3164
+ if (error instanceof Error && error.message.includes("already exists")) {
2700
3165
  this.logger.debug("Reusing existing session after reload", { sessionId });
2701
3166
  this.defaultSession = sessionId;
2702
3167
  await this.ctx.storage.put("defaultSession", sessionId);
@@ -3017,7 +3482,10 @@ var Sandbox = class extends Container {
3017
3482
  }
3018
3483
  constructPreviewUrl(port, sandboxId, hostname, token) {
3019
3484
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`);
3020
- const sanitizedSandboxId = sanitizeSandboxId(sandboxId);
3485
+ const effectiveId = this.sandboxName || sandboxId;
3486
+ const hasUppercase = /[A-Z]/.test(effectiveId);
3487
+ 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.`);
3488
+ const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
3021
3489
  if (isLocalhostPattern(hostname)) {
3022
3490
  const [host, portStr] = hostname.split(":");
3023
3491
  const mainPort = portStr || "80";
@@ -3139,7 +3607,9 @@ var Sandbox = class extends Container {
3139
3607
  },
3140
3608
  runCodeStream: (code, options) => this.codeInterpreter.runCodeStream(code, options),
3141
3609
  listCodeContexts: () => this.codeInterpreter.listCodeContexts(),
3142
- deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId)
3610
+ deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId),
3611
+ mountBucket: (bucket, mountPath, options) => this.mountBucket(bucket, mountPath, options),
3612
+ unmountBucket: (mountPath) => this.unmountBucket(mountPath)
3143
3613
  };
3144
3614
  }
3145
3615
  async createCodeContext(options) {
@@ -3276,5 +3746,5 @@ async function collectFile(stream) {
3276
3746
  }
3277
3747
 
3278
3748
  //#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 };
3749
+ 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
3750
  //# sourceMappingURL=index.js.map