@cloudflare/sandbox 0.7.4 → 0.7.6

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,7 @@
1
1
  import { _ as getEnvString, a as isExecResult, c as shellEscape, d as TraceContext, f as Execution, g as filterEnvVars, h as extractRepoName, i as isWSStreamChunk, l as createLogger, m as GitLogger, n as isWSError, o as isProcess, p as ResultImpl, r as isWSResponse, s as isProcessStatus, t as generateRequestId, u as createNoOpLogger, v as partitionEnvVars } from "./dist-D9B_6gn_.js";
2
- import { t as ErrorCode } from "./errors-Bzl0ZNia.js";
2
+ import { t as ErrorCode } from "./errors-CYUY62c6.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
+ import { AwsClient } from "aws4fetch";
4
5
 
5
6
  //#region src/errors/classes.ts
6
7
  /**
@@ -511,6 +512,75 @@ var ProcessExitedBeforeReadyError = class extends SandboxError {
511
512
  return this.context.exitCode;
512
513
  }
513
514
  };
515
+ /**
516
+ * Error thrown when a backup is not found in R2
517
+ */
518
+ var BackupNotFoundError = class extends SandboxError {
519
+ constructor(errorResponse) {
520
+ super(errorResponse);
521
+ this.name = "BackupNotFoundError";
522
+ }
523
+ get backupId() {
524
+ return this.context.backupId;
525
+ }
526
+ };
527
+ /**
528
+ * Error thrown when a backup has expired (past its TTL)
529
+ */
530
+ var BackupExpiredError = class extends SandboxError {
531
+ constructor(errorResponse) {
532
+ super(errorResponse);
533
+ this.name = "BackupExpiredError";
534
+ }
535
+ get backupId() {
536
+ return this.context.backupId;
537
+ }
538
+ get expiredAt() {
539
+ return this.context.expiredAt;
540
+ }
541
+ };
542
+ /**
543
+ * Error thrown when backup configuration or inputs are invalid
544
+ */
545
+ var InvalidBackupConfigError = class extends SandboxError {
546
+ constructor(errorResponse) {
547
+ super(errorResponse);
548
+ this.name = "InvalidBackupConfigError";
549
+ }
550
+ get reason() {
551
+ return this.context.reason;
552
+ }
553
+ };
554
+ /**
555
+ * Error thrown when backup creation fails
556
+ */
557
+ var BackupCreateError = class extends SandboxError {
558
+ constructor(errorResponse) {
559
+ super(errorResponse);
560
+ this.name = "BackupCreateError";
561
+ }
562
+ get dir() {
563
+ return this.context.dir;
564
+ }
565
+ get backupId() {
566
+ return this.context.backupId;
567
+ }
568
+ };
569
+ /**
570
+ * Error thrown when backup restoration fails
571
+ */
572
+ var BackupRestoreError = class extends SandboxError {
573
+ constructor(errorResponse) {
574
+ super(errorResponse);
575
+ this.name = "BackupRestoreError";
576
+ }
577
+ get dir() {
578
+ return this.context.dir;
579
+ }
580
+ get backupId() {
581
+ return this.context.backupId;
582
+ }
583
+ };
514
584
 
515
585
  //#endregion
516
586
  //#region src/errors/adapter.ts
@@ -557,6 +627,11 @@ function createErrorFromResponse(errorResponse) {
557
627
  case ErrorCode.GIT_CHECKOUT_FAILED: return new GitCheckoutError(errorResponse);
558
628
  case ErrorCode.INVALID_GIT_URL: return new InvalidGitUrlError(errorResponse);
559
629
  case ErrorCode.GIT_OPERATION_FAILED: return new GitError(errorResponse);
630
+ case ErrorCode.BACKUP_NOT_FOUND: return new BackupNotFoundError(errorResponse);
631
+ case ErrorCode.BACKUP_EXPIRED: return new BackupExpiredError(errorResponse);
632
+ case ErrorCode.INVALID_BACKUP_CONFIG: return new InvalidBackupConfigError(errorResponse);
633
+ case ErrorCode.BACKUP_CREATE_FAILED: return new BackupCreateError(errorResponse);
634
+ case ErrorCode.BACKUP_RESTORE_FAILED: return new BackupRestoreError(errorResponse);
560
635
  case ErrorCode.INTERPRETER_NOT_READY: return new InterpreterNotReadyError(errorResponse);
561
636
  case ErrorCode.CONTEXT_NOT_FOUND: return new ContextNotFoundError(errorResponse);
562
637
  case ErrorCode.CODE_EXECUTION_ERROR: return new CodeExecutionError(errorResponse);
@@ -1254,6 +1329,60 @@ var BaseHttpClient = class {
1254
1329
  }
1255
1330
  };
1256
1331
 
1332
+ //#endregion
1333
+ //#region src/clients/backup-client.ts
1334
+ /**
1335
+ * Client for backup operations.
1336
+ *
1337
+ * Handles communication with the container's backup endpoints.
1338
+ * The container creates/extracts squashfs archives locally.
1339
+ * R2 upload/download is handled by the Sandbox DO, not by this client.
1340
+ */
1341
+ var BackupClient = class extends BaseHttpClient {
1342
+ /**
1343
+ * Tell the container to create a squashfs archive from a directory.
1344
+ * @param dir - Directory to back up
1345
+ * @param archivePath - Where the container should write the archive
1346
+ * @param sessionId - Session context
1347
+ */
1348
+ async createArchive(dir, archivePath, sessionId) {
1349
+ try {
1350
+ const data = {
1351
+ dir,
1352
+ archivePath,
1353
+ sessionId
1354
+ };
1355
+ const response = await this.post("/api/backup/create", data);
1356
+ this.logSuccess("Backup archive created", `${dir} -> ${archivePath}`);
1357
+ return response;
1358
+ } catch (error) {
1359
+ this.logError("createArchive", error);
1360
+ throw error;
1361
+ }
1362
+ }
1363
+ /**
1364
+ * Tell the container to restore a squashfs archive into a directory.
1365
+ * @param dir - Target directory
1366
+ * @param archivePath - Path to the archive file in the container
1367
+ * @param sessionId - Session context
1368
+ */
1369
+ async restoreArchive(dir, archivePath, sessionId) {
1370
+ try {
1371
+ const data = {
1372
+ dir,
1373
+ archivePath,
1374
+ sessionId
1375
+ };
1376
+ const response = await this.post("/api/backup/restore", data);
1377
+ this.logSuccess("Backup archive restored", `${archivePath} -> ${dir}`);
1378
+ return response;
1379
+ } catch (error) {
1380
+ this.logError("restoreArchive", error);
1381
+ throw error;
1382
+ }
1383
+ }
1384
+ };
1385
+
1257
1386
  //#endregion
1258
1387
  //#region src/clients/command-client.ts
1259
1388
  /**
@@ -2010,6 +2139,7 @@ var UtilityClient = class extends BaseHttpClient {
2010
2139
  * WebSocket mode reduces sub-request count when running inside Workers/Durable Objects.
2011
2140
  */
2012
2141
  var SandboxClient = class {
2142
+ backup;
2013
2143
  commands;
2014
2144
  files;
2015
2145
  processes;
@@ -2032,6 +2162,7 @@ var SandboxClient = class {
2032
2162
  ...options,
2033
2163
  transport: this.transport ?? options.transport
2034
2164
  };
2165
+ this.backup = new BackupClient(clientOptions);
2035
2166
  this.commands = new CommandClient(clientOptions);
2036
2167
  this.files = new FileClient(clientOptions);
2037
2168
  this.processes = new ProcessClient(clientOptions);
@@ -2567,7 +2698,7 @@ function buildS3fsSource(bucket, prefix) {
2567
2698
  * This file is auto-updated by .github/changeset-version.ts during releases
2568
2699
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
2569
2700
  */
2570
- const SDK_VERSION = "0.7.4";
2701
+ const SDK_VERSION = "0.7.6";
2571
2702
 
2572
2703
  //#endregion
2573
2704
  //#region src/sandbox.ts
@@ -2612,7 +2743,14 @@ function connect(stub) {
2612
2743
  return await stub.fetch(portSwitchedRequest);
2613
2744
  };
2614
2745
  }
2615
- var Sandbox = class extends Container {
2746
+ /**
2747
+ * Type guard for R2Bucket binding.
2748
+ * Checks for the minimal R2Bucket interface methods we use.
2749
+ */
2750
+ function isR2Bucket(value) {
2751
+ return typeof value === "object" && value !== null && "put" in value && typeof value.put === "function" && "get" in value && typeof value.get === "function" && "head" in value && typeof value.head === "function" && "delete" in value && typeof value.delete === "function";
2752
+ }
2753
+ var Sandbox = class Sandbox extends Container {
2616
2754
  defaultPort = 3e3;
2617
2755
  sleepAfter = "10m";
2618
2756
  client;
@@ -2626,6 +2764,24 @@ var Sandbox = class extends Container {
2626
2764
  keepAliveEnabled = false;
2627
2765
  activeMounts = /* @__PURE__ */ new Map();
2628
2766
  transport = "http";
2767
+ backupBucket = null;
2768
+ /**
2769
+ * Serializes backup operations to prevent concurrent create/restore on the same sandbox.
2770
+ *
2771
+ * This is in-memory state — it resets if the Durable Object is evicted and
2772
+ * re-instantiated (e.g. after sleep). This is acceptable because the container
2773
+ * filesystem is also lost on eviction, so there is no archive to race on.
2774
+ */
2775
+ backupInProgress = Promise.resolve();
2776
+ /**
2777
+ * R2 presigned URL credentials for direct container-to-R2 transfers.
2778
+ * All four fields plus the R2 binding must be configured for backup to work.
2779
+ */
2780
+ r2AccessKeyId = null;
2781
+ r2SecretAccessKey = null;
2782
+ r2AccountId = null;
2783
+ backupBucketName = null;
2784
+ r2Client = null;
2629
2785
  /**
2630
2786
  * Default container startup timeouts (conservative for production)
2631
2787
  * Based on Cloudflare docs: "Containers take several minutes to provision"
@@ -2668,6 +2824,16 @@ var Sandbox = class extends Container {
2668
2824
  const transportEnv = envObj?.SANDBOX_TRANSPORT;
2669
2825
  if (transportEnv === "websocket") this.transport = "websocket";
2670
2826
  else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http" or "websocket". Defaulting to "http".`);
2827
+ const backupBucket = envObj?.BACKUP_BUCKET;
2828
+ if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
2829
+ this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
2830
+ this.r2AccessKeyId = getEnvString(envObj, "R2_ACCESS_KEY_ID") ?? null;
2831
+ this.r2SecretAccessKey = getEnvString(envObj, "R2_SECRET_ACCESS_KEY") ?? null;
2832
+ this.backupBucketName = getEnvString(envObj, "BACKUP_BUCKET_NAME") ?? null;
2833
+ if (this.r2AccessKeyId && this.r2SecretAccessKey) this.r2Client = new AwsClient({
2834
+ accessKeyId: this.r2AccessKeyId,
2835
+ secretAccessKey: this.r2SecretAccessKey
2836
+ });
2671
2837
  this.client = this.createSandboxClient();
2672
2838
  this.codeInterpreter = new CodeInterpreter(this);
2673
2839
  this.ctx.blockConcurrencyWhile(async () => {
@@ -3654,7 +3820,7 @@ var Sandbox = class extends Container {
3654
3820
  * @param options - Configuration options
3655
3821
  * @param options.hostname - Your Worker's domain name (required for preview URL construction)
3656
3822
  * @param options.name - Optional friendly name for the port
3657
- * @param options.token - Optional custom token for the preview URL (1-16 characters: lowercase letters, numbers, hyphens, underscores)
3823
+ * @param options.token - Optional custom token for the preview URL (1-16 characters: lowercase letters, numbers, underscores)
3658
3824
  * If not provided, a random 16-character token will be generated automatically
3659
3825
  * @returns Preview URL information including the full URL, port number, and optional name
3660
3826
  *
@@ -3667,9 +3833,9 @@ var Sandbox = class extends Container {
3667
3833
  * // With custom token for stable URLs across deployments
3668
3834
  * const { url } = await sandbox.exposePort(8080, {
3669
3835
  * hostname: 'example.com',
3670
- * token: 'my-token-v1'
3836
+ * token: 'my_token_v1'
3671
3837
  * });
3672
- * // url: https://8080-sandbox-id-my-token-v1.example.com
3838
+ * // url: https://8080-sandbox-id-my_token_v1.example.com
3673
3839
  */
3674
3840
  async exposePort(port, options) {
3675
3841
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
@@ -3896,7 +4062,9 @@ var Sandbox = class extends Container {
3896
4062
  listCodeContexts: () => this.codeInterpreter.listCodeContexts(),
3897
4063
  deleteCodeContext: (contextId) => this.codeInterpreter.deleteCodeContext(contextId),
3898
4064
  mountBucket: (bucket, mountPath, options) => this.mountBucket(bucket, mountPath, options),
3899
- unmountBucket: (mountPath) => this.unmountBucket(mountPath)
4065
+ unmountBucket: (mountPath) => this.unmountBucket(mountPath),
4066
+ createBackup: (options) => this.createBackup(options),
4067
+ restoreBackup: (backup) => this.restoreBackup(backup)
3900
4068
  };
3901
4069
  }
3902
4070
  async createCodeContext(options) {
@@ -3914,6 +4082,474 @@ var Sandbox = class extends Container {
3914
4082
  async deleteCodeContext(contextId) {
3915
4083
  return this.codeInterpreter.deleteCodeContext(contextId);
3916
4084
  }
4085
+ /** UUID v4 format validator for backup IDs */
4086
+ static UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4087
+ /**
4088
+ * Validate that a directory path is safe for backup operations.
4089
+ * Rejects empty, relative, traversal, and null-byte paths.
4090
+ */
4091
+ static validateBackupDir(dir, label) {
4092
+ if (!dir || !dir.startsWith("/")) throw new InvalidBackupConfigError({
4093
+ message: `${label} must be an absolute path`,
4094
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4095
+ httpStatus: 400,
4096
+ context: { reason: `${label} must be an absolute path` },
4097
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4098
+ });
4099
+ if (dir.includes("\0")) throw new InvalidBackupConfigError({
4100
+ message: `${label} must not contain null bytes`,
4101
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4102
+ httpStatus: 400,
4103
+ context: { reason: `${label} must not contain null bytes` },
4104
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4105
+ });
4106
+ if (dir.split("/").includes("..")) throw new InvalidBackupConfigError({
4107
+ message: `${label} must not contain ".." path segments`,
4108
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4109
+ httpStatus: 400,
4110
+ context: { reason: `${label} must not contain ".." path segments` },
4111
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4112
+ });
4113
+ }
4114
+ /**
4115
+ * Returns the R2 bucket or throws if backup is not configured.
4116
+ */
4117
+ requireBackupBucket() {
4118
+ if (!this.backupBucket) throw new InvalidBackupConfigError({
4119
+ message: "Backup not configured. Add a BACKUP_BUCKET R2 binding to your wrangler.jsonc.",
4120
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4121
+ httpStatus: 400,
4122
+ context: { reason: "Missing BACKUP_BUCKET R2 binding" },
4123
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4124
+ });
4125
+ return this.backupBucket;
4126
+ }
4127
+ static PRESIGNED_URL_EXPIRY_SECONDS = 3600;
4128
+ /**
4129
+ * Ensure a dedicated session for backup operations exists.
4130
+ * Isolates backup shell commands (curl, stat, rm, mkdir) from user exec()
4131
+ * calls to prevent session state interference and interleaving.
4132
+ */
4133
+ async ensureBackupSession() {
4134
+ const sessionId = "__sandbox_backup__";
4135
+ try {
4136
+ await this.client.utils.createSession({
4137
+ id: sessionId,
4138
+ cwd: "/"
4139
+ });
4140
+ } catch (error) {
4141
+ if (!(error instanceof SessionAlreadyExistsError)) throw error;
4142
+ }
4143
+ return sessionId;
4144
+ }
4145
+ /**
4146
+ * Returns validated presigned URL configuration or throws if not configured.
4147
+ * All credential fields plus the R2 binding are required for backup to work.
4148
+ */
4149
+ requirePresignedUrlSupport() {
4150
+ if (!this.r2Client || !this.r2AccountId || !this.backupBucketName) {
4151
+ const missing = [];
4152
+ if (!this.r2AccountId) missing.push("CLOUDFLARE_ACCOUNT_ID");
4153
+ if (!this.r2AccessKeyId) missing.push("R2_ACCESS_KEY_ID");
4154
+ if (!this.r2SecretAccessKey) missing.push("R2_SECRET_ACCESS_KEY");
4155
+ if (!this.backupBucketName) missing.push("BACKUP_BUCKET_NAME");
4156
+ throw new InvalidBackupConfigError({
4157
+ message: `Backup requires R2 presigned URL credentials. Missing: ${missing.join(", ")}. Set these as environment variables or secrets in your wrangler.jsonc.`,
4158
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4159
+ httpStatus: 400,
4160
+ context: { reason: `Missing env vars: ${missing.join(", ")}` },
4161
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4162
+ });
4163
+ }
4164
+ return {
4165
+ client: this.r2Client,
4166
+ accountId: this.r2AccountId,
4167
+ bucketName: this.backupBucketName
4168
+ };
4169
+ }
4170
+ /**
4171
+ * Generate a presigned GET URL for downloading an object from R2.
4172
+ * The container can curl this URL directly without credentials.
4173
+ */
4174
+ async generatePresignedGetUrl(r2Key) {
4175
+ const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
4176
+ const encodedBucket = encodeURIComponent(bucketName);
4177
+ const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
4178
+ const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
4179
+ url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
4180
+ return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
4181
+ }
4182
+ /**
4183
+ * Generate a presigned PUT URL for uploading an object to R2.
4184
+ * The container can curl PUT to this URL directly without credentials.
4185
+ */
4186
+ async generatePresignedPutUrl(r2Key) {
4187
+ const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
4188
+ const encodedBucket = encodeURIComponent(bucketName);
4189
+ const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
4190
+ const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
4191
+ url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
4192
+ return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
4193
+ }
4194
+ /**
4195
+ * Upload a backup archive via presigned PUT URL.
4196
+ * The container curls the archive directly to R2, bypassing the DO.
4197
+ * ~24 MB/s throughput vs ~0.6 MB/s for base64 readFile.
4198
+ */
4199
+ async uploadBackupPresigned(archivePath, r2Key, archiveSize, backupId, dir, backupSession) {
4200
+ const presignedUrl = await this.generatePresignedPutUrl(r2Key);
4201
+ this.logger.info("Uploading backup via presigned PUT", {
4202
+ r2Key,
4203
+ archiveSize,
4204
+ backupId
4205
+ });
4206
+ const curlCmd = [
4207
+ "curl -sSf",
4208
+ "-X PUT",
4209
+ "-H 'Content-Type: application/octet-stream'",
4210
+ "--connect-timeout 10",
4211
+ "--max-time 1800",
4212
+ "--retry 2",
4213
+ "--retry-max-time 60",
4214
+ `-T ${shellEscape(archivePath)}`,
4215
+ shellEscape(presignedUrl)
4216
+ ].join(" ");
4217
+ const result = await this.execWithSession(curlCmd, backupSession, { timeout: 181e4 });
4218
+ if (result.exitCode !== 0) throw new BackupCreateError({
4219
+ message: `Presigned URL upload failed (exit code ${result.exitCode}): ${result.stderr}`,
4220
+ code: ErrorCode.BACKUP_CREATE_FAILED,
4221
+ httpStatus: 500,
4222
+ context: {
4223
+ dir,
4224
+ backupId
4225
+ },
4226
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4227
+ });
4228
+ const head = await this.requireBackupBucket().head(r2Key);
4229
+ if (!head || head.size !== archiveSize) {
4230
+ const actualSize = head?.size ?? 0;
4231
+ throw new BackupCreateError({
4232
+ message: `Upload verification failed: expected ${archiveSize} bytes, got ${actualSize}.${result.exitCode === 0 && actualSize === 0 ? " This usually means the BACKUP_BUCKET R2 binding is using local storage while presigned URLs upload to remote R2. Add `\"remote\": true` to your BACKUP_BUCKET R2 binding in wrangler.jsonc to fix this." : ""}`,
4233
+ code: ErrorCode.BACKUP_CREATE_FAILED,
4234
+ httpStatus: 500,
4235
+ context: {
4236
+ dir,
4237
+ backupId
4238
+ },
4239
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4240
+ });
4241
+ }
4242
+ }
4243
+ /**
4244
+ * Download a backup archive via presigned GET URL.
4245
+ * The container curls the archive directly from R2, bypassing the DO.
4246
+ * ~93 MB/s throughput vs ~0.6 MB/s for base64 writeFile.
4247
+ */
4248
+ async downloadBackupPresigned(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
4249
+ const presignedUrl = await this.generatePresignedGetUrl(r2Key);
4250
+ this.logger.info("Downloading backup via presigned GET", {
4251
+ r2Key,
4252
+ expectedSize,
4253
+ backupId
4254
+ });
4255
+ await this.execWithSession("mkdir -p /var/backups", backupSession);
4256
+ const tmpPath = `${archivePath}.tmp`;
4257
+ const curlCmd = [
4258
+ "curl -sSf",
4259
+ "--connect-timeout 10",
4260
+ "--max-time 1800",
4261
+ "--retry 2",
4262
+ "--retry-max-time 60",
4263
+ `-o ${shellEscape(tmpPath)}`,
4264
+ shellEscape(presignedUrl)
4265
+ ].join(" ");
4266
+ const result = await this.execWithSession(curlCmd, backupSession, { timeout: 181e4 });
4267
+ if (result.exitCode !== 0) {
4268
+ await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession).catch(() => {});
4269
+ throw new BackupRestoreError({
4270
+ message: `Presigned URL download failed (exit code ${result.exitCode}): ${result.stderr}`,
4271
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
4272
+ httpStatus: 500,
4273
+ context: {
4274
+ dir,
4275
+ backupId
4276
+ },
4277
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4278
+ });
4279
+ }
4280
+ const sizeCheck = await this.execWithSession(`stat -c %s ${shellEscape(tmpPath)}`, backupSession);
4281
+ const actualSize = parseInt(sizeCheck.stdout.trim(), 10);
4282
+ if (actualSize !== expectedSize) {
4283
+ await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession).catch(() => {});
4284
+ throw new BackupRestoreError({
4285
+ message: `Downloaded archive size mismatch: expected ${expectedSize}, got ${actualSize}`,
4286
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
4287
+ httpStatus: 500,
4288
+ context: {
4289
+ dir,
4290
+ backupId
4291
+ },
4292
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4293
+ });
4294
+ }
4295
+ const mvResult = await this.execWithSession(`mv ${shellEscape(tmpPath)} ${shellEscape(archivePath)}`, backupSession);
4296
+ if (mvResult.exitCode !== 0) {
4297
+ await this.execWithSession(`rm -f ${shellEscape(tmpPath)}`, backupSession).catch(() => {});
4298
+ throw new BackupRestoreError({
4299
+ message: `Failed to finalize downloaded archive: ${mvResult.stderr}`,
4300
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
4301
+ httpStatus: 500,
4302
+ context: {
4303
+ dir,
4304
+ backupId
4305
+ },
4306
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4307
+ });
4308
+ }
4309
+ }
4310
+ /**
4311
+ * Serialize backup operations on this sandbox instance.
4312
+ * Concurrent backup/restore calls are queued so the multi-step
4313
+ * create-archive → read → upload (or download → write → extract) flow
4314
+ * is not interleaved with another backup operation on the same directory.
4315
+ */
4316
+ enqueueBackupOp(fn) {
4317
+ const next = this.backupInProgress.then(fn, () => fn());
4318
+ this.backupInProgress = next.catch(() => {});
4319
+ return next;
4320
+ }
4321
+ /**
4322
+ * Create a backup of a directory and upload it to R2.
4323
+ *
4324
+ * Flow:
4325
+ * 1. Container creates squashfs archive from the directory
4326
+ * 2. Container uploads the archive directly to R2 via presigned URL
4327
+ * 3. DO writes metadata to R2
4328
+ * 4. Container cleans up the local archive
4329
+ *
4330
+ * The returned DirectoryBackup handle is serializable. Store it anywhere
4331
+ * (KV, D1, DO storage) and pass it to restoreBackup() later.
4332
+ *
4333
+ * Concurrent backup/restore calls on the same sandbox are serialized.
4334
+ *
4335
+ * Partially-written files in the target directory may not be captured
4336
+ * consistently. Completed writes are captured.
4337
+ *
4338
+ * NOTE: Expired backups are not automatically deleted from R2. Configure
4339
+ * R2 lifecycle rules on the BACKUP_BUCKET to garbage-collect objects
4340
+ * under the `backups/` prefix after the desired retention period.
4341
+ */
4342
+ async createBackup(options) {
4343
+ this.requireBackupBucket();
4344
+ return this.enqueueBackupOp(() => this.doCreateBackup(options));
4345
+ }
4346
+ async doCreateBackup(options) {
4347
+ const bucket = this.requireBackupBucket();
4348
+ this.requirePresignedUrlSupport();
4349
+ const DEFAULT_TTL_SECONDS = 259200;
4350
+ const MAX_NAME_LENGTH = 256;
4351
+ const { dir, name, ttl = DEFAULT_TTL_SECONDS } = options;
4352
+ Sandbox.validateBackupDir(dir, "BackupOptions.dir");
4353
+ if (name !== void 0) {
4354
+ if (typeof name !== "string" || name.length > MAX_NAME_LENGTH) throw new InvalidBackupConfigError({
4355
+ message: `BackupOptions.name must be a string of at most ${MAX_NAME_LENGTH} characters`,
4356
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4357
+ httpStatus: 400,
4358
+ context: { reason: `name must be a string of at most ${MAX_NAME_LENGTH} characters` },
4359
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4360
+ });
4361
+ if (/[\u0000-\u001f\u007f]/.test(name)) throw new InvalidBackupConfigError({
4362
+ message: "BackupOptions.name must not contain control characters",
4363
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4364
+ httpStatus: 400,
4365
+ context: { reason: "name must not contain control characters" },
4366
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4367
+ });
4368
+ }
4369
+ if (ttl <= 0) throw new InvalidBackupConfigError({
4370
+ message: "BackupOptions.ttl must be a positive number of seconds",
4371
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4372
+ httpStatus: 400,
4373
+ context: { reason: "ttl must be a positive number of seconds" },
4374
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4375
+ });
4376
+ const backupSession = await this.ensureBackupSession();
4377
+ const backupId = crypto.randomUUID();
4378
+ const archivePath = `/var/backups/${backupId}.sqsh`;
4379
+ this.logger.info("Creating backup", {
4380
+ backupId,
4381
+ dir,
4382
+ name
4383
+ });
4384
+ const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession);
4385
+ if (!createResult.success) throw new BackupCreateError({
4386
+ message: "Container failed to create backup archive",
4387
+ code: ErrorCode.BACKUP_CREATE_FAILED,
4388
+ httpStatus: 500,
4389
+ context: {
4390
+ dir,
4391
+ backupId
4392
+ },
4393
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4394
+ });
4395
+ const r2Key = `backups/${backupId}/data.sqsh`;
4396
+ const metaKey = `backups/${backupId}/meta.json`;
4397
+ try {
4398
+ await this.uploadBackupPresigned(archivePath, r2Key, createResult.sizeBytes, backupId, dir, backupSession);
4399
+ const metadata = {
4400
+ id: backupId,
4401
+ dir,
4402
+ name: name || null,
4403
+ sizeBytes: createResult.sizeBytes,
4404
+ ttl,
4405
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4406
+ };
4407
+ await bucket.put(metaKey, JSON.stringify(metadata));
4408
+ this.logger.info("Backup uploaded to R2", {
4409
+ backupId,
4410
+ r2Key,
4411
+ sizeBytes: createResult.sizeBytes
4412
+ });
4413
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession).catch(() => {});
4414
+ return {
4415
+ id: backupId,
4416
+ dir
4417
+ };
4418
+ } catch (error) {
4419
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession).catch(() => {});
4420
+ await bucket.delete(r2Key).catch(() => {});
4421
+ await bucket.delete(metaKey).catch(() => {});
4422
+ throw error;
4423
+ }
4424
+ }
4425
+ /**
4426
+ * Restore a backup from R2 into a directory.
4427
+ *
4428
+ * Flow:
4429
+ * 1. DO reads metadata from R2 and checks TTL
4430
+ * 2. Container downloads the archive directly from R2 via presigned URL
4431
+ * 3. Container mounts the squashfs archive with FUSE overlayfs
4432
+ *
4433
+ * The target directory becomes an overlay mount with the backup as a
4434
+ * read-only lower layer and a writable upper layer for copy-on-write.
4435
+ * Any processes writing to the directory should be stopped first.
4436
+ *
4437
+ * **Mount Lifecycle**: The FUSE overlay mount persists only while the
4438
+ * container is running. When the sandbox sleeps or the container restarts,
4439
+ * the mount is lost and the directory becomes empty. Re-restore from the
4440
+ * backup handle to recover. This is an ephemeral restore, not a persistent
4441
+ * extraction.
4442
+ *
4443
+ * The backup is restored into `backup.dir`. This may differ from the
4444
+ * directory that was originally backed up, allowing cross-directory restore.
4445
+ *
4446
+ * Overlapping backups are independent: restoring a parent directory
4447
+ * overwrites everything inside it, including subdirectories that were
4448
+ * backed up separately. When restoring both, restore the parent first.
4449
+ *
4450
+ * Concurrent backup/restore calls on the same sandbox are serialized.
4451
+ */
4452
+ async restoreBackup(backup) {
4453
+ this.requireBackupBucket();
4454
+ return this.enqueueBackupOp(() => this.doRestoreBackup(backup));
4455
+ }
4456
+ async doRestoreBackup(backup) {
4457
+ const bucket = this.requireBackupBucket();
4458
+ this.requirePresignedUrlSupport();
4459
+ const { id: backupId, dir } = backup;
4460
+ if (!backupId || typeof backupId !== "string") throw new InvalidBackupConfigError({
4461
+ message: "Invalid backup: missing or invalid id",
4462
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4463
+ httpStatus: 400,
4464
+ context: { reason: "missing or invalid id" },
4465
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4466
+ });
4467
+ if (!Sandbox.UUID_REGEX.test(backupId)) throw new InvalidBackupConfigError({
4468
+ message: "Invalid backup: id must be a valid UUID (e.g. from createBackup)",
4469
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
4470
+ httpStatus: 400,
4471
+ context: { reason: "id must be a valid UUID" },
4472
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4473
+ });
4474
+ Sandbox.validateBackupDir(dir, "Invalid backup: dir");
4475
+ this.logger.info("Restoring backup", {
4476
+ backupId,
4477
+ dir
4478
+ });
4479
+ const metaKey = `backups/${backupId}/meta.json`;
4480
+ const metaObject = await bucket.get(metaKey);
4481
+ if (!metaObject) throw new BackupNotFoundError({
4482
+ message: `Backup not found: ${backupId}. Verify the backup ID is correct and the backup has not been deleted.`,
4483
+ code: ErrorCode.BACKUP_NOT_FOUND,
4484
+ httpStatus: 404,
4485
+ context: { backupId },
4486
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4487
+ });
4488
+ const metadata = await metaObject.json();
4489
+ const TTL_BUFFER_MS = 60 * 1e3;
4490
+ const createdAt = new Date(metadata.createdAt).getTime();
4491
+ if (Number.isNaN(createdAt)) throw new BackupRestoreError({
4492
+ message: `Backup metadata has invalid createdAt timestamp: ${metadata.createdAt}`,
4493
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
4494
+ httpStatus: 500,
4495
+ context: {
4496
+ dir,
4497
+ backupId
4498
+ },
4499
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4500
+ });
4501
+ const expiresAt = createdAt + metadata.ttl * 1e3;
4502
+ if (Date.now() + TTL_BUFFER_MS > expiresAt) throw new BackupExpiredError({
4503
+ message: `Backup ${backupId} has expired (created: ${metadata.createdAt}, TTL: ${metadata.ttl}s). Create a new backup.`,
4504
+ code: ErrorCode.BACKUP_EXPIRED,
4505
+ httpStatus: 400,
4506
+ context: {
4507
+ backupId,
4508
+ expiredAt: new Date(expiresAt).toISOString()
4509
+ },
4510
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4511
+ });
4512
+ const r2Key = `backups/${backupId}/data.sqsh`;
4513
+ const archiveHead = await bucket.head(r2Key);
4514
+ if (!archiveHead) throw new BackupNotFoundError({
4515
+ message: `Backup archive not found in R2: ${backupId}. The archive may have been deleted by R2 lifecycle rules.`,
4516
+ code: ErrorCode.BACKUP_NOT_FOUND,
4517
+ httpStatus: 404,
4518
+ context: { backupId },
4519
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4520
+ });
4521
+ const backupSession = await this.ensureBackupSession();
4522
+ const archivePath = `/var/backups/${backupId}.sqsh`;
4523
+ try {
4524
+ const mountGlob = `/var/backups/mounts/${backupId}`;
4525
+ await this.execWithSession(`/usr/bin/fusermount3 -uz ${shellEscape(dir)} 2>/dev/null || true`, backupSession).catch(() => {});
4526
+ await this.execWithSession(`for d in ${shellEscape(mountGlob)}_*/lower ${shellEscape(mountGlob)}/lower; do [ -d "$d" ] && /usr/bin/fusermount3 -uz "$d" 2>/dev/null; done; true`, backupSession).catch(() => {});
4527
+ const sizeCheck = await this.execWithSession(`stat -c %s ${shellEscape(archivePath)} 2>/dev/null || echo 0`, backupSession).catch(() => ({ stdout: "0" }));
4528
+ if (Number.parseInt((sizeCheck.stdout ?? "0").trim(), 10) !== archiveHead.size) await this.downloadBackupPresigned(archivePath, r2Key, archiveHead.size, backupId, dir, backupSession);
4529
+ if (!(await this.client.backup.restoreArchive(dir, archivePath, backupSession)).success) throw new BackupRestoreError({
4530
+ message: "Container failed to restore backup archive",
4531
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
4532
+ httpStatus: 500,
4533
+ context: {
4534
+ dir,
4535
+ backupId
4536
+ },
4537
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4538
+ });
4539
+ this.logger.info("Backup restored", {
4540
+ backupId,
4541
+ dir
4542
+ });
4543
+ return {
4544
+ success: true,
4545
+ dir,
4546
+ id: backupId
4547
+ };
4548
+ } catch (error) {
4549
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession).catch(() => {});
4550
+ throw error;
4551
+ }
4552
+ }
3917
4553
  };
3918
4554
 
3919
4555
  //#endregion
@@ -4036,5 +4672,5 @@ async function collectFile(stream) {
4036
4672
  }
4037
4673
 
4038
4674
  //#endregion
4039
- export { BucketMountError, CodeInterpreter, CommandClient, FileClient, GitClient, InvalidMountConfigError, MissingCredentialsError, PortClient, ProcessClient, ProcessExitedBeforeReadyError, ProcessReadyTimeoutError, S3FSMountError, Sandbox, SandboxClient, UtilityClient, asyncIterableToSSEStream, collectFile, getSandbox, isExecResult, isProcess, isProcessStatus, parseSSEStream, proxyTerminal, proxyToSandbox, responseToAsyncIterable, streamFile };
4675
+ export { BackupClient, BackupCreateError, BackupExpiredError, BackupNotFoundError, BackupRestoreError, BucketMountError, CodeInterpreter, CommandClient, FileClient, GitClient, InvalidBackupConfigError, InvalidMountConfigError, MissingCredentialsError, PortClient, ProcessClient, ProcessExitedBeforeReadyError, ProcessReadyTimeoutError, S3FSMountError, Sandbox, SandboxClient, UtilityClient, asyncIterableToSSEStream, collectFile, getSandbox, isExecResult, isProcess, isProcessStatus, parseSSEStream, proxyTerminal, proxyToSandbox, responseToAsyncIterable, streamFile };
4040
4676
  //# sourceMappingURL=index.js.map