@cloudflare/sandbox 0.10.1 → 0.10.3

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.
@@ -1,9 +1,10 @@
1
1
  import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-B_eXrP83.js";
2
- import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CBi-O-pF.js";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-8Hvune8K.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
- import { RpcSession } from "capnweb";
5
+ import { RpcSession, RpcTarget } from "capnweb";
6
6
  import path from "node:path/posix";
7
+ import { RpcTarget as RpcTarget$1 } from "cloudflare:workers";
7
8
 
8
9
  //#region src/errors/classes.ts
9
10
  /**
@@ -1784,6 +1785,17 @@ var CommandClient = class extends BaseHttpClient {
1784
1785
  //#endregion
1785
1786
  //#region src/clients/desktop-client.ts
1786
1787
  /**
1788
+ * Decode a base64-encoded screenshot payload into a Uint8Array.
1789
+ * Shared with the RPC client wrapper, which receives base64 over the
1790
+ * wire and needs the same `format: 'bytes'` convenience.
1791
+ */
1792
+ function base64ToBytes(data) {
1793
+ const binaryString = atob(data);
1794
+ const bytes = new Uint8Array(binaryString.length);
1795
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1796
+ return bytes;
1797
+ }
1798
+ /**
1787
1799
  * Client for desktop environment lifecycle, input, and screen operations
1788
1800
  */
1789
1801
  var DesktopClient = class extends BaseHttpClient {
@@ -1828,15 +1840,10 @@ var DesktopClient = class extends BaseHttpClient {
1828
1840
  ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1829
1841
  };
1830
1842
  const response = await this.post("/api/desktop/screenshot", data);
1831
- if (wantsBytes) {
1832
- const binaryString = atob(response.data);
1833
- const bytes = new Uint8Array(binaryString.length);
1834
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1835
- return {
1836
- ...response,
1837
- data: bytes
1838
- };
1839
- }
1843
+ if (wantsBytes) return {
1844
+ ...response,
1845
+ data: base64ToBytes(response.data)
1846
+ };
1840
1847
  return response;
1841
1848
  }
1842
1849
  async screenshotRegion(region, options) {
@@ -1849,15 +1856,10 @@ var DesktopClient = class extends BaseHttpClient {
1849
1856
  ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1850
1857
  };
1851
1858
  const response = await this.post("/api/desktop/screenshot/region", data);
1852
- if (wantsBytes) {
1853
- const binaryString = atob(response.data);
1854
- const bytes = new Uint8Array(binaryString.length);
1855
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1856
- return {
1857
- ...response,
1858
- data: bytes
1859
- };
1860
- }
1859
+ if (wantsBytes) return {
1860
+ ...response,
1861
+ data: base64ToBytes(response.data)
1862
+ };
1861
1863
  return response;
1862
1864
  }
1863
1865
  /**
@@ -2010,7 +2012,7 @@ var DesktopClient = class extends BaseHttpClient {
2010
2012
  * Get health status for a specific desktop process.
2011
2013
  */
2012
2014
  async getProcessStatus(name) {
2013
- return await this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2015
+ return this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2014
2016
  }
2015
2017
  };
2016
2018
 
@@ -2523,6 +2525,9 @@ var UtilityClient = class extends BaseHttpClient {
2523
2525
  return "unknown";
2524
2526
  }
2525
2527
  }
2528
+ listSessions() {
2529
+ throw new Error("listSessions requires the RPC transport. Set SANDBOX_TRANSPORT=rpc.");
2530
+ }
2526
2531
  };
2527
2532
 
2528
2533
  //#endregion
@@ -2646,6 +2651,13 @@ var SandboxClient = class {
2646
2651
  utils;
2647
2652
  desktop;
2648
2653
  watch;
2654
+ /**
2655
+ * Tunnels are RPC-only — the route-based transport does not implement them.
2656
+ * This getter exists so the `PublicKeys<SandboxClient> satisfies
2657
+ * PublicKeys<SandboxAPI>` compile-time check holds. Calling any method on
2658
+ * the returned proxy throws a clear `RPC transport required` error.
2659
+ */
2660
+ tunnels = createTunnelsNotImplemented();
2649
2661
  transport = null;
2650
2662
  constructor(options) {
2651
2663
  if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
@@ -2723,6 +2735,14 @@ var SandboxClient = class {
2723
2735
  if (this.transport) this.transport.disconnect();
2724
2736
  }
2725
2737
  };
2738
+ function createTunnelsNotImplemented() {
2739
+ const message = "sandbox.tunnels.* requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.";
2740
+ return new Proxy({}, { get() {
2741
+ return () => {
2742
+ throw new Error(message);
2743
+ };
2744
+ } });
2745
+ }
2726
2746
 
2727
2747
  //#endregion
2728
2748
  //#region ../shared/src/backup.ts
@@ -2745,6 +2765,10 @@ function normalizeBackupExcludePattern(pattern) {
2745
2765
  return normalized;
2746
2766
  }
2747
2767
 
2768
+ //#endregion
2769
+ //#region ../shared/src/internal.ts
2770
+ const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
2771
+
2748
2772
  //#endregion
2749
2773
  //#region src/container-control/connection.ts
2750
2774
  const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
@@ -2769,13 +2793,15 @@ var ContainerControlConnection = class {
2769
2793
  port;
2770
2794
  logger;
2771
2795
  retryTimeoutMs;
2796
+ onClose;
2772
2797
  constructor(options) {
2773
2798
  this.containerStub = options.stub;
2774
2799
  this.port = options.port ?? 3e3;
2775
2800
  this.logger = options.logger ?? createNoOpLogger();
2776
2801
  this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
2802
+ this.onClose = options.onClose;
2777
2803
  this.transport = new DeferredTransport();
2778
- this.session = new RpcSession(this.transport);
2804
+ this.session = new RpcSession(this.transport, options.localMain);
2779
2805
  this.stub = this.session.getRemoteMain();
2780
2806
  }
2781
2807
  /**
@@ -2815,6 +2841,8 @@ var ContainerControlConnection = class {
2815
2841
  this.stub[Symbol.dispose]?.();
2816
2842
  } catch {}
2817
2843
  if (this.ws) {
2844
+ this.ws.removeEventListener("close", this.onWebSocketClose);
2845
+ this.ws.removeEventListener("error", this.onWebSocketError);
2818
2846
  try {
2819
2847
  this.ws.close();
2820
2848
  } catch {}
@@ -2831,6 +2859,42 @@ var ContainerControlConnection = class {
2831
2859
  setRetryTimeoutMs(ms) {
2832
2860
  this.retryTimeoutMs = ms;
2833
2861
  }
2862
+ /**
2863
+ * Run the owner-provided `onClose` callback exactly once per call,
2864
+ * swallowing any errors so a buggy listener can't keep the connection
2865
+ * object in a half-torn-down state.
2866
+ */
2867
+ fireOnClose() {
2868
+ if (!this.onClose) return;
2869
+ try {
2870
+ this.onClose();
2871
+ } catch (err) {
2872
+ this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
2873
+ }
2874
+ }
2875
+ /**
2876
+ * WebSocket `close` listener. Defined as a bound arrow field so the
2877
+ * same reference can be passed to both `addEventListener` and
2878
+ * `removeEventListener` — a fresh anonymous lambda would silently
2879
+ * fail to unbind.
2880
+ */
2881
+ onWebSocketClose = () => {
2882
+ const wasConnected = this.connected;
2883
+ this.connected = false;
2884
+ this.ws = null;
2885
+ this.logger.debug("ContainerControlConnection WebSocket closed");
2886
+ if (wasConnected) this.fireOnClose();
2887
+ };
2888
+ /**
2889
+ * WebSocket `error` listener. Same field-form rationale as
2890
+ * {@link onWebSocketClose}.
2891
+ */
2892
+ onWebSocketError = () => {
2893
+ const wasConnected = this.connected;
2894
+ this.connected = false;
2895
+ this.ws = null;
2896
+ if (wasConnected) this.fireOnClose();
2897
+ };
2834
2898
  async doConnect() {
2835
2899
  try {
2836
2900
  const response = await this.fetchUpgradeWithRetry();
@@ -2838,15 +2902,8 @@ var ContainerControlConnection = class {
2838
2902
  const ws = response.webSocket;
2839
2903
  if (!ws) throw new Error("No WebSocket in upgrade response");
2840
2904
  ws.accept();
2841
- ws.addEventListener("close", () => {
2842
- this.connected = false;
2843
- this.ws = null;
2844
- this.logger.debug("ContainerControlConnection WebSocket closed");
2845
- });
2846
- ws.addEventListener("error", () => {
2847
- this.connected = false;
2848
- this.ws = null;
2849
- });
2905
+ ws.addEventListener("close", this.onWebSocketClose);
2906
+ ws.addEventListener("error", this.onWebSocketError);
2850
2907
  this.ws = ws;
2851
2908
  this.transport.activate(ws);
2852
2909
  this.connected = true;
@@ -3004,12 +3061,33 @@ const IDLE_EXPORT_THRESHOLD = 1;
3004
3061
  /**
3005
3062
  * Translate a capnweb-propagated error into a typed SandboxError.
3006
3063
  *
3007
- * capnweb only preserves `error.name` and `error.message` across the wire.
3008
- * The container encodes the full error as a JSON object in the message
3009
- * string: `{"code":"...","message":"...","context":{...}}`.
3064
+ * Two wire formats are supported for backward compatibility with older
3065
+ * container images:
3066
+ *
3067
+ * 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
3068
+ * `ServiceError`-shaped object with own enumerable `code` and `details`
3069
+ * properties. capnweb walks `Object.keys()` and reconstructs those fields
3070
+ * on the SDK side.
3071
+ * 2. Legacy JSON-encoded message. Older containers encoded the structured
3072
+ * payload as a JSON string in `error.message`.
3073
+ *
3074
+ * The JSON-fallback branch can be removed once all older container images are
3075
+ * no longer in service.
3010
3076
  */
3011
3077
  function translateRPCError(error) {
3012
3078
  if (error instanceof Error) {
3079
+ const propagated = error;
3080
+ if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
3081
+ const code = propagated.code;
3082
+ const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
3083
+ throw createErrorFromResponse({
3084
+ code,
3085
+ message: error.message,
3086
+ context,
3087
+ httpStatus: getHttpStatus(code),
3088
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3089
+ });
3090
+ }
3013
3091
  let payload;
3014
3092
  try {
3015
3093
  payload = JSON.parse(error.message);
@@ -3126,19 +3204,16 @@ var ContainerControlClient = class {
3126
3204
  busyPollTimer = null;
3127
3205
  /** Tracks whether we currently believe the session is busy. */
3128
3206
  busy = false;
3129
- /**
3130
- * Set the first time the poller observes `conn.isConnected() === true`,
3131
- * cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
3132
- * upgrade is still in progress" (don't tear down) from "we were
3133
- * connected and the peer went away" (do tear down).
3134
- */
3135
- wasEverConnected = false;
3136
3207
  constructor(options) {
3137
3208
  this.connOptions = {
3138
3209
  stub: options.stub,
3139
3210
  port: options.port,
3211
+ localMain: options.localMain,
3140
3212
  logger: options.logger,
3141
- retryTimeoutMs: options.retryTimeoutMs
3213
+ retryTimeoutMs: options.retryTimeoutMs,
3214
+ onClose: () => {
3215
+ if (this.conn) this.destroyConnection();
3216
+ }
3142
3217
  };
3143
3218
  this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
3144
3219
  this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
@@ -3180,11 +3255,7 @@ var ContainerControlClient = class {
3180
3255
  pollBusyState = () => {
3181
3256
  const conn = this.conn;
3182
3257
  if (!conn) return;
3183
- if (!conn.isConnected()) {
3184
- if (this.wasEverConnected) this.destroyConnection();
3185
- return;
3186
- }
3187
- this.wasEverConnected = true;
3258
+ if (!conn.isConnected()) return;
3188
3259
  const { imports, exports } = conn.getStats();
3189
3260
  if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
3190
3261
  if (!this.busy) {
@@ -3217,7 +3288,7 @@ var ContainerControlClient = class {
3217
3288
  if (!conn || !conn.isConnected()) return;
3218
3289
  const { imports, exports } = conn.getStats();
3219
3290
  if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
3220
- this.logger.debug("Disconnecting idle capnweb connection");
3291
+ this.logger.debug("Disconnecting idle RPC connection");
3221
3292
  this.destroyConnection();
3222
3293
  }
3223
3294
  }, this.idleDisconnectMs);
@@ -3239,7 +3310,6 @@ var ContainerControlClient = class {
3239
3310
  this.conn.disconnect();
3240
3311
  this.conn = null;
3241
3312
  }
3242
- this.wasEverConnected = false;
3243
3313
  }
3244
3314
  get commands() {
3245
3315
  return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
@@ -3263,11 +3333,37 @@ var ContainerControlClient = class {
3263
3333
  return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3264
3334
  }
3265
3335
  get desktop() {
3266
- return wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3336
+ const stub = wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3337
+ const wire = stub;
3338
+ return new Proxy(stub, { get(target, prop, receiver) {
3339
+ if (prop === "screenshot") return async (options) => {
3340
+ const { format, ...rest } = options ?? {};
3341
+ const result = await wire.screenshot(rest);
3342
+ return format === "bytes" ? {
3343
+ ...result,
3344
+ data: base64ToBytes(result.data)
3345
+ } : result;
3346
+ };
3347
+ if (prop === "screenshotRegion") return async (region, options) => {
3348
+ const { format, ...rest } = options ?? {};
3349
+ const result = await wire.screenshotRegion({
3350
+ region,
3351
+ ...rest
3352
+ });
3353
+ return format === "bytes" ? {
3354
+ ...result,
3355
+ data: base64ToBytes(result.data)
3356
+ } : result;
3357
+ };
3358
+ return Reflect.get(target, prop, receiver);
3359
+ } });
3267
3360
  }
3268
3361
  get watch() {
3269
3362
  return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3270
3363
  }
3364
+ get tunnels() {
3365
+ return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
3366
+ }
3271
3367
  get interpreter() {
3272
3368
  return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3273
3369
  }
@@ -3808,6 +3904,13 @@ function resolveS3fsOptions(provider, userOptions) {
3808
3904
 
3809
3905
  //#endregion
3810
3906
  //#region src/storage-mount/validation.ts
3907
+ /**
3908
+ * Type guard for R2Bucket binding.
3909
+ * Checks for the minimal R2Bucket interface methods we use.
3910
+ */
3911
+ function isR2Bucket(value) {
3912
+ 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" && "list" in value && typeof value.list === "function";
3913
+ }
3811
3914
  function validatePrefix(prefix) {
3812
3915
  if (!prefix.startsWith("/")) throw new InvalidMountConfigError(`Prefix must start with '/': "${prefix}"`);
3813
3916
  }
@@ -3818,6 +3921,13 @@ function validateBucketName(bucket, mountPath) {
3818
3921
  }
3819
3922
  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.`);
3820
3923
  }
3924
+ function validateBucketBindingName(bucketBinding, mountPath) {
3925
+ if (bucketBinding.includes(":")) {
3926
+ const [bucketName, prefixPart] = bucketBinding.split(":");
3927
+ throw new InvalidMountConfigError(`Bucket name cannot contain ':'. To mount a prefix, use the 'prefix' option:\n mountBucket('${bucketName}', '${mountPath}', { ...options, prefix: '${prefixPart}' })`);
3928
+ }
3929
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(bucketBinding)) throw new InvalidMountConfigError(`Invalid R2 binding name: "${bucketBinding}". Binding names must start with a letter or underscore and contain only letters, numbers, or underscores.`);
3930
+ }
3821
3931
  /**
3822
3932
  * Builds the s3fs source string from bucket name and optional prefix.
3823
3933
  * Format: "bucket" or "bucket:/prefix/" for subdirectory mounts.
@@ -4142,7 +4252,7 @@ async function proxyTerminal(stub, sessionId, request, options) {
4142
4252
 
4143
4253
  //#endregion
4144
4254
  //#region src/request-handler.ts
4145
- async function proxyToSandbox(request, env) {
4255
+ async function proxyToSandbox(request, env$1) {
4146
4256
  const logger = createLogger({
4147
4257
  component: "sandbox-do",
4148
4258
  traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
@@ -4153,7 +4263,7 @@ async function proxyToSandbox(request, env) {
4153
4263
  const routeInfo = extractSandboxRoute(url);
4154
4264
  if (!routeInfo) return null;
4155
4265
  const { sandboxId, port, path: path$1, token } = routeInfo;
4156
- const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
4266
+ const sandbox = getSandbox(env$1.Sandbox, sandboxId, { normalizeId: true });
4157
4267
  if (port !== 3e3) {
4158
4268
  if (!await sandbox.validatePortToken(port, token)) {
4159
4269
  logger.warn("Invalid token access blocked", {
@@ -4239,6 +4349,513 @@ function isLocalhostPattern(hostname) {
4239
4349
  return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
4240
4350
  }
4241
4351
 
4352
+ //#endregion
4353
+ //#region src/storage-mount/r2-egress-handler.ts
4354
+ const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
4355
+ const XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
4356
+ function escapeXML(s) {
4357
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4358
+ }
4359
+ function xmlResponse(body, status = 200) {
4360
+ return new Response(XML_DECL + body, {
4361
+ status,
4362
+ headers: { "Content-Type": "application/xml" }
4363
+ });
4364
+ }
4365
+ function normalizeObjectKey(value) {
4366
+ return value.replace(/^\/+/, "");
4367
+ }
4368
+ function trimTrailingSlashes(s) {
4369
+ let end = s.length;
4370
+ while (end > 0 && s[end - 1] === "/") end--;
4371
+ return s.slice(0, end);
4372
+ }
4373
+ function parsePath(pathname) {
4374
+ const stripped = pathname.startsWith("/") ? pathname.slice(1) : pathname;
4375
+ if (!stripped) return null;
4376
+ const slash = stripped.indexOf("/");
4377
+ if (slash === -1) return {
4378
+ bucket: stripped,
4379
+ key: ""
4380
+ };
4381
+ return {
4382
+ bucket: stripped.slice(0, slash),
4383
+ key: normalizeObjectKey(stripped.slice(slash + 1))
4384
+ };
4385
+ }
4386
+ function resolveR2Bucket(env$1, name) {
4387
+ if (typeof env$1 !== "object" || env$1 === null) return null;
4388
+ const val = env$1[name];
4389
+ return isR2Bucket(val) ? val : null;
4390
+ }
4391
+ function parseRange(header) {
4392
+ if (!header) return void 0;
4393
+ const m = header.match(/^bytes=(\d*)-(\d*)$/);
4394
+ if (!m) return void 0;
4395
+ const start = m[1] ? parseInt(m[1], 10) : void 0;
4396
+ const end = m[2] ? parseInt(m[2], 10) : void 0;
4397
+ if (start === void 0 && end !== void 0) return { suffix: end };
4398
+ if (start !== void 0 && end !== void 0) return {
4399
+ offset: start,
4400
+ length: end - start + 1
4401
+ };
4402
+ if (start !== void 0) return { offset: start };
4403
+ }
4404
+ function buildListObjectsV2Xml(bucketName, prefix, delimiter, maxKeys, result) {
4405
+ const contents = result.objects.map((obj) => `<Contents><Key>${escapeXML(obj.key)}</Key><LastModified>${obj.uploaded.toISOString()}</LastModified><ETag>${escapeXML(obj.httpEtag)}</ETag><Size>${obj.size}</Size><StorageClass>STANDARD</StorageClass></Contents>`).join("");
4406
+ const commonPrefixes = result.delimitedPrefixes.map((p) => `<CommonPrefixes><Prefix>${escapeXML(p)}</Prefix></CommonPrefixes>`).join("");
4407
+ const nextToken = result.truncated && result.cursor ? `<NextContinuationToken>${escapeXML(result.cursor)}</NextContinuationToken>` : "";
4408
+ const keyCount = result.objects.length + result.delimitedPrefixes.length;
4409
+ return `<ListBucketResult ${XML_NS}><Name>${escapeXML(bucketName)}</Name><Prefix>${escapeXML(prefix)}</Prefix><KeyCount>${keyCount}</KeyCount><MaxKeys>${maxKeys}</MaxKeys>` + (delimiter ? `<Delimiter>${escapeXML(delimiter)}</Delimiter>` : "") + `<IsTruncated>${result.truncated}</IsTruncated>` + nextToken + contents + commonPrefixes + `</ListBucketResult>`;
4410
+ }
4411
+ function buildLocationXml() {
4412
+ return `<LocationConstraint ${XML_NS}/>`;
4413
+ }
4414
+ function buildInitiateMultipartUploadXml(bucketName, key, uploadId) {
4415
+ return `<InitiateMultipartUploadResult ${XML_NS}><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><UploadId>${escapeXML(uploadId)}</UploadId></InitiateMultipartUploadResult>`;
4416
+ }
4417
+ function buildCompleteMultipartUploadXml(bucketName, key, etag) {
4418
+ return `<CompleteMultipartUploadResult ${XML_NS}><Location>http://r2.internal/${escapeXML(bucketName)}/${escapeXML(key)}</Location><Bucket>${escapeXML(bucketName)}</Bucket><Key>${escapeXML(key)}</Key><ETag>${escapeXML(etag)}</ETag></CompleteMultipartUploadResult>`;
4419
+ }
4420
+ function buildCopyObjectXml(etag, uploaded) {
4421
+ return `<CopyObjectResult ${XML_NS}><LastModified>${uploaded.toISOString()}</LastModified><ETag>${escapeXML(etag)}</ETag></CopyObjectResult>`;
4422
+ }
4423
+ function extractXmlTagContent(segment, tagName) {
4424
+ const openTag = `<${tagName}>`;
4425
+ const closeTag = `</${tagName}>`;
4426
+ const start = segment.indexOf(openTag);
4427
+ if (start === -1) return null;
4428
+ const contentStart = start + openTag.length;
4429
+ const end = segment.indexOf(closeTag, contentStart);
4430
+ if (end === -1) return null;
4431
+ return segment.slice(contentStart, end);
4432
+ }
4433
+ function parseCompleteMultipartUploadBody(body) {
4434
+ const parts = [];
4435
+ let pos = 0;
4436
+ while (pos < body.length) {
4437
+ const start = body.indexOf("<Part>", pos);
4438
+ if (start === -1) break;
4439
+ const end = body.indexOf("</Part>", start + 6);
4440
+ if (end === -1) break;
4441
+ const segment = body.slice(start, end + 7);
4442
+ pos = end + 7;
4443
+ const partNumberText = extractXmlTagContent(segment, "PartNumber");
4444
+ const etagText = extractXmlTagContent(segment, "ETag");
4445
+ const partNumber = partNumberText ? parseInt(partNumberText, 10) : NaN;
4446
+ if (Number.isFinite(partNumber) && etagText) parts.push({
4447
+ partNumber,
4448
+ etag: etagText.replace(/^"|"$/g, "")
4449
+ });
4450
+ }
4451
+ return parts;
4452
+ }
4453
+ function buildResponseHeaders(obj) {
4454
+ const headers = new Headers();
4455
+ headers.set("ETag", obj.httpEtag);
4456
+ headers.set("Content-Length", String(obj.size));
4457
+ headers.set("Last-Modified", obj.uploaded.toUTCString());
4458
+ headers.set("Accept-Ranges", "bytes");
4459
+ if (obj.httpMetadata?.contentType) headers.set("Content-Type", obj.httpMetadata.contentType);
4460
+ if (obj.httpMetadata?.contentDisposition) headers.set("Content-Disposition", obj.httpMetadata.contentDisposition);
4461
+ if (obj.httpMetadata?.contentEncoding) headers.set("Content-Encoding", obj.httpMetadata.contentEncoding);
4462
+ if (obj.httpMetadata?.contentLanguage) headers.set("Content-Language", obj.httpMetadata.contentLanguage);
4463
+ if (obj.httpMetadata?.cacheControl) headers.set("Cache-Control", obj.httpMetadata.cacheControl);
4464
+ return headers;
4465
+ }
4466
+ function buildContentRange(range, totalSize) {
4467
+ if ("suffix" in range) return `bytes ${Math.max(0, totalSize - range.suffix)}-${totalSize - 1}/${totalSize}`;
4468
+ const start = range.offset ?? 0;
4469
+ return `bytes ${start}-${range.length !== void 0 ? start + range.length - 1 : totalSize - 1}/${totalSize}`;
4470
+ }
4471
+ function getRangeContentLength(range, totalSize) {
4472
+ if ("suffix" in range) return Math.min(range.suffix, totalSize);
4473
+ const start = range.offset ?? 0;
4474
+ if (start >= totalSize) return 0;
4475
+ const requestedLength = range.length !== void 0 ? range.length : totalSize - start;
4476
+ return Math.min(requestedLength, totalSize - start);
4477
+ }
4478
+ function extractHttpMetadata(request) {
4479
+ const meta = {};
4480
+ const ct = request.headers.get("Content-Type");
4481
+ if (ct) meta.contentType = ct;
4482
+ const cd = request.headers.get("Content-Disposition");
4483
+ if (cd) meta.contentDisposition = cd;
4484
+ const ce = request.headers.get("Content-Encoding");
4485
+ if (ce) meta.contentEncoding = ce;
4486
+ const cl = request.headers.get("Content-Language");
4487
+ if (cl) meta.contentLanguage = cl;
4488
+ const cc = request.headers.get("Cache-Control");
4489
+ if (cc) meta.cacheControl = cc;
4490
+ return meta;
4491
+ }
4492
+ function parseCopySource(header) {
4493
+ const sourcePath = header.split("?")[0] ?? "";
4494
+ if (!sourcePath) return null;
4495
+ const decoded = decodeURIComponent(sourcePath);
4496
+ const parsed = parsePath(decoded.startsWith("/") ? decoded : `/${decoded}`);
4497
+ return parsed ? {
4498
+ bucket: parsed.bucket,
4499
+ key: normalizeObjectKey(parsed.key)
4500
+ } : null;
4501
+ }
4502
+ function normalizeStorageClass(storageClass) {
4503
+ if (storageClass === "Standard" || storageClass === "InfrequentAccess") return storageClass;
4504
+ }
4505
+ async function putRequestBody(r2, key, request, options) {
4506
+ const contentLength = request.headers.get("Content-Length");
4507
+ const length = contentLength ? Number.parseInt(contentLength, 10) : NaN;
4508
+ if (!Number.isFinite(length) || length < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
4509
+ if (length === 0) return r2.put(key, new Uint8Array(0), options);
4510
+ if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
4511
+ const { readable, writable } = new FixedLengthStream(length);
4512
+ const pipe = request.body.pipeTo(writable);
4513
+ const result = await r2.put(key, readable, options);
4514
+ await pipe;
4515
+ return result;
4516
+ }
4517
+ async function handleListObjects(r2, bucketName, url, mountPrefix) {
4518
+ const queryPrefix = normalizeObjectKey(url.searchParams.get("prefix") ?? "");
4519
+ const delimiter = url.searchParams.get("delimiter") ?? "";
4520
+ const maxKeys = Math.min(parseInt(url.searchParams.get("max-keys") ?? "1000", 10) || 1e3, 1e3);
4521
+ const continuationToken = url.searchParams.get("continuation-token") ?? void 0;
4522
+ const listOpts = {
4523
+ prefix: (mountPrefix ? `${mountPrefix}/${queryPrefix}` : queryPrefix) || void 0,
4524
+ delimiter: delimiter || void 0,
4525
+ limit: maxKeys,
4526
+ cursor: continuationToken
4527
+ };
4528
+ const result = await r2.list(listOpts);
4529
+ const stripKey = mountPrefix ? (k) => k.startsWith(`${mountPrefix}/`) ? k.slice(mountPrefix.length + 1) : k : (k) => k;
4530
+ return xmlResponse(buildListObjectsV2Xml(bucketName, queryPrefix, delimiter, maxKeys, {
4531
+ objects: result.objects.map((obj) => ({
4532
+ key: stripKey(obj.key),
4533
+ uploaded: obj.uploaded,
4534
+ httpEtag: obj.httpEtag,
4535
+ size: obj.size
4536
+ })),
4537
+ delimitedPrefixes: result.delimitedPrefixes.map(stripKey),
4538
+ truncated: result.truncated,
4539
+ cursor: result.truncated ? result.cursor : void 0
4540
+ }));
4541
+ }
4542
+ async function handleHeadObject(r2, key) {
4543
+ const obj = await r2.head(key);
4544
+ if (!obj) return new Response(null, { status: 404 });
4545
+ return new Response(null, {
4546
+ status: 200,
4547
+ headers: buildResponseHeaders(obj)
4548
+ });
4549
+ }
4550
+ async function handleGetObject(r2, key, request) {
4551
+ const range = parseRange(request.headers.get("Range"));
4552
+ if (!range) {
4553
+ const obj = await r2.get(key);
4554
+ if (!obj) return new Response(null, { status: 404 });
4555
+ return new Response(obj.body, {
4556
+ status: 200,
4557
+ headers: buildResponseHeaders(obj)
4558
+ });
4559
+ }
4560
+ const [headObj, rangeObj] = await Promise.all([r2.head(key), r2.get(key, { range })]);
4561
+ if (!headObj || !rangeObj) return new Response(null, { status: 404 });
4562
+ const headers = buildResponseHeaders(rangeObj);
4563
+ headers.set("Content-Range", buildContentRange(range, headObj.size));
4564
+ headers.set("Content-Length", String(getRangeContentLength(range, headObj.size)));
4565
+ return new Response(rangeObj.body, {
4566
+ status: 206,
4567
+ headers
4568
+ });
4569
+ }
4570
+ async function handlePutObject(r2, bucketName, key, request, env$1, permitted, mountPrefix) {
4571
+ const copySourceHeader = request.headers.get("x-amz-copy-source");
4572
+ if (copySourceHeader) {
4573
+ const copySource = parseCopySource(copySourceHeader);
4574
+ if (!copySource || !copySource.key) return new Response("Bad Request: invalid x-amz-copy-source", { status: 400 });
4575
+ if (!permitted.has(copySource.bucket)) return new Response(`Access to R2 bucket "${copySource.bucket}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
4576
+ const sourceBucket = copySource.bucket === bucketName ? r2 : resolveR2Bucket(env$1, copySource.bucket);
4577
+ if (!sourceBucket) return new Response(`R2 binding "${copySource.bucket}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
4578
+ const sourceKey = mountPrefix && copySource.bucket === bucketName ? `${mountPrefix}/${copySource.key}` : copySource.key;
4579
+ const sourceObject = await sourceBucket.get(sourceKey);
4580
+ if (!sourceObject) return new Response(null, { status: 404 });
4581
+ const httpMetadata = request.headers.get("x-amz-metadata-directive")?.toUpperCase() === "REPLACE" ? extractHttpMetadata(request) : sourceObject.httpMetadata;
4582
+ const result$1 = await r2.put(key, sourceObject.body, {
4583
+ httpMetadata,
4584
+ customMetadata: sourceObject.customMetadata,
4585
+ storageClass: normalizeStorageClass(sourceObject.storageClass)
4586
+ });
4587
+ return xmlResponse(buildCopyObjectXml(result$1.httpEtag, result$1.uploaded));
4588
+ }
4589
+ const result = await putRequestBody(r2, key, request, { httpMetadata: extractHttpMetadata(request) });
4590
+ if (result instanceof Response) return result;
4591
+ const headers = new Headers();
4592
+ headers.set("ETag", result.httpEtag);
4593
+ return new Response(null, {
4594
+ status: 200,
4595
+ headers
4596
+ });
4597
+ }
4598
+ async function handleDeleteObject(r2, key) {
4599
+ await r2.delete(key);
4600
+ return new Response(null, { status: 204 });
4601
+ }
4602
+ async function handleCreateMultipartUpload(r2, bucketName, key, request) {
4603
+ const httpMetadata = extractHttpMetadata(request);
4604
+ return xmlResponse(buildInitiateMultipartUploadXml(bucketName, key, (await r2.createMultipartUpload(key, { httpMetadata })).uploadId));
4605
+ }
4606
+ async function handleUploadPart(r2, key, url, request) {
4607
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4608
+ const partNumber = parseInt(url.searchParams.get("partNumber") ?? "0", 10);
4609
+ if (!uploadId || !partNumber) return new Response("Bad Request: missing uploadId or partNumber", { status: 400 });
4610
+ if (!request.body) return new Response("Bad Request: missing request body", { status: 400 });
4611
+ const contentLength = request.headers.get("Content-Length");
4612
+ const partLength = contentLength ? Number.parseInt(contentLength, 10) : NaN;
4613
+ if (!Number.isFinite(partLength) || partLength < 0) return new Response("Bad Request: missing or invalid Content-Length", { status: 400 });
4614
+ const upload = r2.resumeMultipartUpload(key, uploadId);
4615
+ let part;
4616
+ if (partLength === 0) part = await upload.uploadPart(partNumber, new Uint8Array(0));
4617
+ else {
4618
+ const { readable, writable } = new FixedLengthStream(partLength);
4619
+ const pipe = request.body.pipeTo(writable);
4620
+ part = await upload.uploadPart(partNumber, readable);
4621
+ await pipe;
4622
+ }
4623
+ const headers = new Headers();
4624
+ headers.set("ETag", `"${part.etag}"`);
4625
+ return new Response(null, {
4626
+ status: 200,
4627
+ headers
4628
+ });
4629
+ }
4630
+ async function handleCompleteMultipartUpload(r2, bucketName, key, url, request) {
4631
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4632
+ if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
4633
+ const r2Parts = parseCompleteMultipartUploadBody(await request.text()).map((p) => ({
4634
+ partNumber: p.partNumber,
4635
+ etag: p.etag
4636
+ }));
4637
+ return xmlResponse(buildCompleteMultipartUploadXml(bucketName, key, (await r2.resumeMultipartUpload(key, uploadId).complete(r2Parts)).httpEtag));
4638
+ }
4639
+ async function handleAbortMultipartUpload(r2, key, url) {
4640
+ const uploadId = url.searchParams.get("uploadId") ?? "";
4641
+ if (!uploadId) return new Response("Bad Request: missing uploadId", { status: 400 });
4642
+ await r2.resumeMultipartUpload(key, uploadId).abort();
4643
+ return new Response(null, { status: 204 });
4644
+ }
4645
+ const r2EgressHandler = async (request, env$1, ctx) => {
4646
+ const url = new URL(request.url);
4647
+ const parsed = parsePath(url.pathname);
4648
+ if (!parsed) return new Response("Bad Request: empty path", { status: 400 });
4649
+ const { bucket: bucketName, key } = parsed;
4650
+ if (!ctx.params?.buckets || !(bucketName in ctx.params.buckets)) return new Response(`Access to R2 bucket "${bucketName}" is not permitted. Call mountBucket() with this bucket before accessing it.`, { status: 403 });
4651
+ const bucketParams = ctx.params.buckets[bucketName];
4652
+ const rawPrefix = bucketParams.prefix;
4653
+ const mountPrefix = rawPrefix ? trimTrailingSlashes(normalizeObjectKey(rawPrefix)) : void 0;
4654
+ const readOnly = bucketParams.readOnly ?? false;
4655
+ const r2 = resolveR2Bucket(env$1, bucketName);
4656
+ if (!r2) return new Response(`R2 binding "${bucketName}" not found in Worker env. Ensure the binding name matches the bucket name passed to mountBucket().`, { status: 500 });
4657
+ const { method } = request;
4658
+ if (!key) {
4659
+ if (method === "GET" && url.searchParams.has("location")) return xmlResponse(buildLocationXml());
4660
+ if (method === "GET" && url.searchParams.get("list-type") === "2") return handleListObjects(r2, bucketName, url, mountPrefix);
4661
+ if (method === "GET") return handleListObjects(r2, bucketName, url, mountPrefix);
4662
+ return new Response("Method Not Allowed", { status: 405 });
4663
+ }
4664
+ const fullKey = mountPrefix ? `${mountPrefix}/${key}` : key;
4665
+ const permitted = new Set(Object.keys(ctx.params.buckets));
4666
+ if (readOnly && (method === "PUT" || method === "DELETE" || method === "POST" && (url.searchParams.has("uploads") || url.searchParams.has("uploadId")))) return new Response("Forbidden: bucket mount is read-only", { status: 403 });
4667
+ if (method === "POST" && url.searchParams.has("uploads")) return handleCreateMultipartUpload(r2, bucketName, fullKey, request);
4668
+ if (method === "POST" && url.searchParams.has("uploadId")) return handleCompleteMultipartUpload(r2, bucketName, fullKey, url, request);
4669
+ if (method === "PUT" && url.searchParams.has("partNumber") && url.searchParams.has("uploadId")) return handleUploadPart(r2, fullKey, url, request);
4670
+ if (method === "DELETE" && url.searchParams.has("uploadId")) return handleAbortMultipartUpload(r2, fullKey, url);
4671
+ switch (method) {
4672
+ case "HEAD": return handleHeadObject(r2, fullKey);
4673
+ case "GET": return handleGetObject(r2, fullKey, request);
4674
+ case "PUT": return handlePutObject(r2, bucketName, fullKey, request, env$1, permitted, mountPrefix);
4675
+ case "DELETE": return handleDeleteObject(r2, fullKey);
4676
+ default: return new Response("Method Not Allowed", { status: 405 });
4677
+ }
4678
+ };
4679
+
4680
+ //#endregion
4681
+ //#region src/tunnels/sandbox-control-callback.ts
4682
+ var SandboxControlCallbackImpl = class extends RpcTarget {
4683
+ constructor(getHandler, logger) {
4684
+ super();
4685
+ this.getHandler = getHandler;
4686
+ this.logger = logger;
4687
+ }
4688
+ async onTunnelExit(id, port, exitCode) {
4689
+ const handler = this.getHandler();
4690
+ if (!handler) {
4691
+ this.logger.debug("onTunnelExit: no handler bound; ignoring", {
4692
+ id,
4693
+ port,
4694
+ exitCode
4695
+ });
4696
+ return;
4697
+ }
4698
+ await handler(id, port, exitCode);
4699
+ }
4700
+ };
4701
+
4702
+ //#endregion
4703
+ //#region src/tunnels/tunnels-handler.ts
4704
+ /**
4705
+ * Tunnels namespace handler. Created once per Sandbox DO instance via
4706
+ * `createTunnelsHandler(host)` and exposed as `sandbox.tunnels`.
4707
+ *
4708
+ * Storage is the source of truth. The DO holds a `Record<portString, TunnelInfo>`
4709
+ * under the `tunnels` storage key. `Sandbox.onStart()` clears the key on every
4710
+ * container restart so any record in storage is by construction backed by a
4711
+ * running `cloudflared` process; the handler never needs to verify that
4712
+ * separately against the container.
4713
+ */
4714
+ /** DO storage key for the `port → TunnelInfo` map. */
4715
+ const STORAGE_KEY = "tunnels";
4716
+ function validateTunnelPort(port) {
4717
+ if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
4718
+ }
4719
+ /** 8-char hex id derived from `crypto.getRandomValues`. Unique per sandbox. */
4720
+ function shortId() {
4721
+ const buf = new Uint8Array(4);
4722
+ crypto.getRandomValues(buf);
4723
+ return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
4724
+ }
4725
+ function isTunnelNotFoundError(error) {
4726
+ return (error instanceof Error ? error.message : String(error)).includes("TUNNEL_NOT_FOUND");
4727
+ }
4728
+ async function readMap(storage) {
4729
+ return await storage.get(STORAGE_KEY) ?? {};
4730
+ }
4731
+ /**
4732
+ * Concrete `TunnelsHandler` implementation. Extends `RpcTarget` so it
4733
+ * can cross the Workers RPC boundary: the Sandbox DO is reachable from
4734
+ * Workers via Workers RPC (`stub.tunnels.get(port)`), and only
4735
+ * `RpcTarget` instances are passed by reference across that boundary.
4736
+ */
4737
+ var TunnelsRpcTarget = class extends RpcTarget$1 {
4738
+ #host;
4739
+ #withPortLock;
4740
+ constructor(host, withPortLock) {
4741
+ super();
4742
+ this.#host = host;
4743
+ this.#withPortLock = withPortLock;
4744
+ }
4745
+ async get(port) {
4746
+ const startTime = Date.now();
4747
+ let outcome = "error";
4748
+ let cacheState = "miss";
4749
+ let caughtError;
4750
+ try {
4751
+ validateTunnelPort(port);
4752
+ const info = await this.#withPortLock(port, async () => {
4753
+ const existing = (await readMap(this.#host.storage))[port.toString()];
4754
+ if (existing) {
4755
+ cacheState = "hit";
4756
+ return existing;
4757
+ }
4758
+ const id = `quick-${shortId()}`;
4759
+ const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
4760
+ await this.#host.storage.transaction(async (txn) => {
4761
+ const nextMap = await readMap(txn);
4762
+ nextMap[port.toString()] = spawned;
4763
+ await txn.put(STORAGE_KEY, nextMap);
4764
+ });
4765
+ return spawned;
4766
+ });
4767
+ outcome = "success";
4768
+ return info;
4769
+ } catch (error) {
4770
+ caughtError = error instanceof Error ? error : new Error(String(error));
4771
+ throw error;
4772
+ } finally {
4773
+ logCanonicalEvent(this.#host.logger, {
4774
+ event: "tunnel.get",
4775
+ outcome,
4776
+ port,
4777
+ cacheState,
4778
+ durationMs: Date.now() - startTime,
4779
+ error: caughtError
4780
+ });
4781
+ }
4782
+ }
4783
+ async destroy(portOrInfo) {
4784
+ const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
4785
+ const startTime = Date.now();
4786
+ let outcome = "error";
4787
+ let caughtError;
4788
+ let tunnelId;
4789
+ try {
4790
+ await this.#withPortLock(port, async () => {
4791
+ const existing = (await readMap(this.#host.storage))[port.toString()];
4792
+ if (!existing) return;
4793
+ tunnelId = existing.id;
4794
+ await this.#host.storage.transaction(async (txn) => {
4795
+ const current = await readMap(txn);
4796
+ delete current[port.toString()];
4797
+ await txn.put(STORAGE_KEY, current);
4798
+ });
4799
+ try {
4800
+ await this.#host.client.tunnels.destroyTunnel(existing.id);
4801
+ } catch (error) {
4802
+ if (!isTunnelNotFoundError(error)) throw error;
4803
+ }
4804
+ });
4805
+ outcome = "success";
4806
+ } catch (error) {
4807
+ caughtError = error instanceof Error ? error : new Error(String(error));
4808
+ throw error;
4809
+ } finally {
4810
+ logCanonicalEvent(this.#host.logger, {
4811
+ event: "tunnel.destroy",
4812
+ outcome,
4813
+ port,
4814
+ tunnelId,
4815
+ durationMs: Date.now() - startTime,
4816
+ error: caughtError
4817
+ });
4818
+ }
4819
+ }
4820
+ async list() {
4821
+ const map = await readMap(this.#host.storage);
4822
+ return Object.values(map);
4823
+ }
4824
+ };
4825
+ function createTunnelsHandler(host) {
4826
+ const portLocks = /* @__PURE__ */ new Map();
4827
+ const withPortLock = (port, fn) => {
4828
+ const next = (portLocks.get(port) ?? Promise.resolve()).then(fn, fn);
4829
+ portLocks.set(port, next.catch(() => void 0));
4830
+ return next;
4831
+ };
4832
+ const tunnels = new TunnelsRpcTarget(host, withPortLock);
4833
+ const handleTunnelExit = async (id, port, exitCode) => {
4834
+ const startTime = Date.now();
4835
+ await withPortLock(port, async () => {
4836
+ await host.storage.transaction(async (txn) => {
4837
+ const map = await readMap(txn);
4838
+ if (map[port.toString()]?.id === id) {
4839
+ delete map[port.toString()];
4840
+ await txn.put(STORAGE_KEY, map);
4841
+ }
4842
+ });
4843
+ logCanonicalEvent(host.logger, {
4844
+ event: "tunnel.exit",
4845
+ outcome: "success",
4846
+ port,
4847
+ tunnelId: id,
4848
+ exitCode: exitCode ?? void 0,
4849
+ durationMs: Date.now() - startTime
4850
+ });
4851
+ });
4852
+ };
4853
+ return {
4854
+ tunnels,
4855
+ handleTunnelExit
4856
+ };
4857
+ }
4858
+
4242
4859
  //#endregion
4243
4860
  //#region src/version.ts
4244
4861
  /**
@@ -4246,11 +4863,23 @@ function isLocalhostPattern(hostname) {
4246
4863
  * This file is auto-updated by .github/changeset-version.ts during releases
4247
4864
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
4248
4865
  */
4249
- const SDK_VERSION = "0.10.1";
4866
+ const SDK_VERSION = "0.10.3";
4250
4867
 
4251
4868
  //#endregion
4252
4869
  //#region src/sandbox.ts
4870
+ const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
4871
+ var R2EgressProxyTarget = class extends Container {};
4872
+ Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
4873
+ R2EgressProxyTarget.outboundHandlers = { r2EgressMount: r2EgressHandler };
4874
+ function isFetcher(value) {
4875
+ return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
4876
+ }
4253
4877
  const sandboxConfigurationCache = /* @__PURE__ */ new WeakMap();
4878
+ const R2_DEFAULT_S3FS_OPTIONS = {
4879
+ stat_cache_expire: "60",
4880
+ enable_noobj_cache: true,
4881
+ multipart_size: "5"
4882
+ };
4254
4883
  const BACKUP_DEFAULT_TTL_SECONDS = 259200;
4255
4884
  const BACKUP_MAX_NAME_LENGTH = 256;
4256
4885
  const BACKUP_CONTAINER_DIR = "/var/backups";
@@ -4383,19 +5012,72 @@ function getSandbox(ns, id, options) {
4383
5012
  });
4384
5013
  }
4385
5014
  const defaultSessionId = `sandbox-${effectiveId}`;
5015
+ const useDefaultSession = options?.enableDefaultSession !== false;
4386
5016
  const enhancedMethods = {
4387
5017
  fetch: (request) => stub.fetch(request),
5018
+ exec: (command, execOptions) => useDefaultSession ? stub.exec(command, execOptions) : stub.execWithSessionToken(command, DISABLE_SESSION_TOKEN, execOptions),
5019
+ startProcess: (command, processOptions) => useDefaultSession || processOptions?.sessionId !== void 0 ? stub.startProcess(command, processOptions) : stub.startProcess(command, {
5020
+ ...processOptions,
5021
+ sessionId: DISABLE_SESSION_TOKEN
5022
+ }),
5023
+ listProcesses: (sessionId) => useDefaultSession || sessionId !== void 0 ? stub.listProcesses(sessionId) : stub.listProcesses(DISABLE_SESSION_TOKEN),
5024
+ getProcess: (id$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.getProcess(id$1, sessionId) : stub.getProcess(id$1, DISABLE_SESSION_TOKEN),
5025
+ execStream: (command, streamOptions) => {
5026
+ if (useDefaultSession || streamOptions?.sessionId !== void 0) return stub.execStream(command, streamOptions);
5027
+ return stub.execStreamWithSessionToken(command, DISABLE_SESSION_TOKEN, streamOptions);
5028
+ },
5029
+ writeFile: (path$1, content, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.writeFile(path$1, content, fileOptions) : stub.writeFile(path$1, content, {
5030
+ ...fileOptions,
5031
+ sessionId: DISABLE_SESSION_TOKEN
5032
+ }),
5033
+ readFile: (path$1, fileOptions = {}) => {
5034
+ const options$1 = useDefaultSession || fileOptions.sessionId !== void 0 ? fileOptions : {
5035
+ ...fileOptions,
5036
+ sessionId: DISABLE_SESSION_TOKEN
5037
+ };
5038
+ if (options$1.encoding === "none") return stub.readFile(path$1, options$1);
5039
+ return stub.readFile(path$1, options$1);
5040
+ },
5041
+ readFileStream: (path$1, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.readFileStream(path$1, fileOptions) : stub.readFileStream(path$1, { sessionId: DISABLE_SESSION_TOKEN }),
5042
+ mkdir: (path$1, mkdirOptions = {}) => useDefaultSession || mkdirOptions.sessionId !== void 0 ? stub.mkdir(path$1, mkdirOptions) : stub.mkdir(path$1, {
5043
+ ...mkdirOptions,
5044
+ sessionId: DISABLE_SESSION_TOKEN
5045
+ }),
5046
+ deleteFile: (path$1) => useDefaultSession ? stub.deleteFile(path$1) : stub.deleteFile(path$1, DISABLE_SESSION_TOKEN),
5047
+ renameFile: (oldPath, newPath) => useDefaultSession ? stub.renameFile(oldPath, newPath) : stub.renameFile(oldPath, newPath, DISABLE_SESSION_TOKEN),
5048
+ moveFile: (sourcePath, destinationPath) => useDefaultSession ? stub.moveFile(sourcePath, destinationPath) : stub.moveFile(sourcePath, destinationPath, DISABLE_SESSION_TOKEN),
5049
+ listFiles: (path$1, listOptions) => useDefaultSession || listOptions?.sessionId !== void 0 ? stub.listFiles(path$1, listOptions) : stub.listFiles(path$1, {
5050
+ ...listOptions,
5051
+ sessionId: DISABLE_SESSION_TOKEN
5052
+ }),
5053
+ exists: (path$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.exists(path$1, sessionId) : stub.exists(path$1, DISABLE_SESSION_TOKEN),
5054
+ gitCheckout: (repoUrl, gitOptions) => useDefaultSession || gitOptions?.sessionId !== void 0 ? stub.gitCheckout(repoUrl, gitOptions) : stub.gitCheckout(repoUrl, {
5055
+ ...gitOptions,
5056
+ sessionId: DISABLE_SESSION_TOKEN
5057
+ }),
4388
5058
  createSession: async (opts) => {
4389
5059
  return enhanceSession(stub, await stub.createSession(opts));
4390
5060
  },
4391
5061
  getSession: async (sessionId) => {
4392
5062
  return enhanceSession(stub, await stub.getSession(sessionId));
4393
5063
  },
5064
+ watch: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.watch(path$1, options$1) : stub.watch(path$1, {
5065
+ ...options$1,
5066
+ sessionId: DISABLE_SESSION_TOKEN
5067
+ }),
5068
+ checkChanges: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.checkChanges(path$1, options$1) : stub.checkChanges(path$1, {
5069
+ ...options$1,
5070
+ sessionId: DISABLE_SESSION_TOKEN
5071
+ }),
4394
5072
  terminal: (request, opts) => proxyTerminal(stub, defaultSessionId, request, opts),
4395
5073
  wsConnect: connect(stub),
4396
5074
  desktop: new Proxy({}, { get(_, method) {
4397
5075
  if (typeof method !== "string" || method === "then") return void 0;
4398
5076
  return (...args) => stub.callDesktop(method, args);
5077
+ } }),
5078
+ tunnels: new Proxy({}, { get: (_, method) => {
5079
+ if (typeof method !== "string" || method === "then") return void 0;
5080
+ return (...args) => stub.callTunnels(method, args);
4399
5081
  } })
4400
5082
  };
4401
5083
  return new Proxy(stub, { get(target, prop) {
@@ -4416,19 +5098,15 @@ function connect(stub) {
4416
5098
  return await stub.fetch(portSwitchedRequest);
4417
5099
  };
4418
5100
  }
4419
- /**
4420
- * Type guard for R2Bucket binding.
4421
- * Checks for the minimal R2Bucket interface methods we use.
4422
- */
4423
- function isR2Bucket(value) {
4424
- 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";
4425
- }
4426
5101
  var Sandbox = class Sandbox extends Container {
4427
5102
  defaultPort = 3e3;
4428
5103
  sleepAfter = "10m";
4429
5104
  client;
4430
5105
  codeInterpreter;
4431
5106
  sandboxName = null;
5107
+ tunnelsHandler = null;
5108
+ tunnelExitHandler = null;
5109
+ controlCallback;
4432
5110
  normalizeId = false;
4433
5111
  defaultSession = null;
4434
5112
  containerGeneration = 0;
@@ -4527,13 +5205,30 @@ var Sandbox = class Sandbox extends Container {
4527
5205
  * Dispatch method for desktop operations.
4528
5206
  * Called by the client-side proxy created in getSandbox() to provide
4529
5207
  * the `sandbox.desktop.status()` API without relying on RPC pipelining
4530
- * through property getters.
5208
+ * through property getters which is broken when using vite-plugin.
4531
5209
  */
4532
5210
  async callDesktop(method, args) {
4533
5211
  if (!Sandbox.DESKTOP_METHODS.has(method)) throw new Error(`Unknown desktop method: ${method}`);
4534
5212
  const client = this.client.desktop;
4535
5213
  const fn = client[method];
4536
- if (typeof fn !== "function") throw new Error(`Unknown desktop method: ${method}`);
5214
+ if (typeof fn !== "function") throw new Error(`sandbox.desktop missing method: ${method}`);
5215
+ return fn.apply(client, args);
5216
+ }
5217
+ /**
5218
+ * Dispatch method for tunnel operations.
5219
+ * Called by the client-side proxy created in getSandbox() to provide
5220
+ * the `sandbox.tunnels` API without relying on RPC pipelining
5221
+ * through property getters which is broken when using vite-plugin.
5222
+ */
5223
+ async callTunnels(method, args) {
5224
+ if (![
5225
+ "get",
5226
+ "list",
5227
+ "destroy"
5228
+ ].includes(method)) throw new Error(`Unknown tunnels method: ${method}`);
5229
+ const client = this.tunnels;
5230
+ const fn = client[method];
5231
+ if (typeof fn !== "function") throw new Error(`sandbox.tunnels missing method: ${method}`);
4537
5232
  return fn.apply(client, args);
4538
5233
  }
4539
5234
  /**
@@ -4579,6 +5274,7 @@ var Sandbox = class Sandbox extends Container {
4579
5274
  port: 3e3,
4580
5275
  logger: this.logger,
4581
5276
  retryTimeoutMs: this.computeRetryTimeoutMs(),
5277
+ localMain: this.controlCallback,
4582
5278
  onActivity: () => {
4583
5279
  this.renewActivityTimeout();
4584
5280
  },
@@ -4593,9 +5289,9 @@ var Sandbox = class Sandbox extends Container {
4593
5289
  }
4594
5290
  return this.createSandboxClient();
4595
5291
  }
4596
- constructor(ctx, env) {
4597
- super(ctx, env);
4598
- const envObj = env;
5292
+ constructor(ctx, env$1) {
5293
+ super(ctx, env$1);
5294
+ const envObj = env$1;
4599
5295
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
4600
5296
  if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
4601
5297
  });
@@ -4618,6 +5314,7 @@ var Sandbox = class Sandbox extends Container {
4618
5314
  accessKeyId: this.r2AccessKeyId,
4619
5315
  secretAccessKey: this.r2SecretAccessKey
4620
5316
  });
5317
+ this.controlCallback = new SandboxControlCallbackImpl(() => this.tunnelExitHandler, this.logger);
4621
5318
  this.client = this.createClientForTransport(this.transport);
4622
5319
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
4623
5320
  this.ctx.blockConcurrencyWhile(async () => {
@@ -4645,6 +5342,8 @@ var Sandbox = class Sandbox extends Container {
4645
5342
  const previousClient = this.client;
4646
5343
  this.client = this.createClientForTransport(storedTransport);
4647
5344
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5345
+ this.tunnelsHandler = null;
5346
+ this.tunnelExitHandler = null;
4648
5347
  previousClient.disconnect();
4649
5348
  }
4650
5349
  if (storedTransport) this.hasStoredTransport = true;
@@ -4700,13 +5399,6 @@ var Sandbox = class Sandbox extends Container {
4700
5399
  }
4701
5400
  }
4702
5401
  }
4703
- /**
4704
- * RPC method to configure container startup timeouts. Idempotent once
4705
- * the values have been persisted: re-applying the same timeout set is a
4706
- * no-op. The transport retry budget is recomputed only when at least
4707
- * one timeout actually changes. Storage is written before the in-memory
4708
- * mirror and derived state are updated.
4709
- */
4710
5402
  async setContainerTimeouts(timeouts) {
4711
5403
  const validated = { ...this.containerTimeouts };
4712
5404
  if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
@@ -4719,11 +5411,6 @@ var Sandbox = class Sandbox extends Container {
4719
5411
  this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
4720
5412
  this.logger.debug("Container timeouts updated", this.containerTimeouts);
4721
5413
  }
4722
- /**
4723
- * RPC method to set the transport protocol. Idempotent once the value
4724
- * has been persisted: re-applying the same transport is a no-op.
4725
- * Storage is written before the in-memory state and client are updated.
4726
- */
4727
5414
  async setTransport(transport) {
4728
5415
  if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
4729
5416
  this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
@@ -4736,24 +5423,18 @@ var Sandbox = class Sandbox extends Container {
4736
5423
  this.hasStoredTransport = true;
4737
5424
  this.client = this.createClientForTransport(transport);
4738
5425
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5426
+ this.tunnelsHandler = null;
5427
+ this.tunnelExitHandler = null;
4739
5428
  previousClient.disconnect();
4740
5429
  this.renewActivityTimeout();
4741
5430
  this.logger.debug("Transport updated", { transport });
4742
5431
  }
4743
- /**
4744
- * Validate a timeout value is within acceptable range
4745
- * Throws error if invalid - used for user-provided values
4746
- */
4747
5432
  validateTimeout(value, name, min, max) {
4748
5433
  if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
4749
5434
  if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
4750
5435
  return value;
4751
5436
  }
4752
- /**
4753
- * Get default timeouts with env var fallbacks and validation
4754
- * Precedence: SDK defaults < Env vars < User config
4755
- */
4756
- getDefaultTimeouts(env) {
5437
+ getDefaultTimeouts(env$1) {
4757
5438
  const parseAndValidate = (envVar, name, min, max) => {
4758
5439
  const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
4759
5440
  if (envVar === void 0) return defaultValue;
@@ -4769,9 +5450,9 @@ var Sandbox = class Sandbox extends Container {
4769
5450
  return parsed;
4770
5451
  };
4771
5452
  return {
4772
- instanceGetTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
4773
- portReadyTimeoutMS: parseAndValidate(getEnvString(env, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
4774
- waitIntervalMS: parseAndValidate(getEnvString(env, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
5453
+ instanceGetTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_INSTANCE_TIMEOUT_MS"), "instanceGetTimeoutMS", 5e3, 3e5),
5454
+ portReadyTimeoutMS: parseAndValidate(getEnvString(env$1, "SANDBOX_PORT_TIMEOUT_MS"), "portReadyTimeoutMS", 1e4, 6e5),
5455
+ waitIntervalMS: parseAndValidate(getEnvString(env$1, "SANDBOX_POLL_INTERVAL_MS"), "waitIntervalMS", 100, 5e3)
4775
5456
  };
4776
5457
  }
4777
5458
  /**
@@ -4793,7 +5474,16 @@ var Sandbox = class Sandbox extends Container {
4793
5474
  await this.mountBucketLocal(bucket, mountPath, options);
4794
5475
  return;
4795
5476
  }
4796
- await this.mountBucketFuse(bucket, mountPath, options);
5477
+ const remoteOptions = options;
5478
+ if (remoteOptions.endpoint === void 0) {
5479
+ const binding = this.env[bucket];
5480
+ if (isR2Bucket(binding)) {
5481
+ await this.mountBucketR2Egress(bucket, mountPath, options);
5482
+ return;
5483
+ }
5484
+ throw new InvalidMountConfigError(`R2 binding "${bucket}" not found in Worker env. Ensure the binding name matches the bucket binding configured in wrangler.jsonc.`);
5485
+ }
5486
+ await this.mountBucketFuse(bucket, mountPath, remoteOptions);
4797
5487
  }
4798
5488
  /**
4799
5489
  * Local dev mount: bidirectional sync via R2 binding + file/watch APIs
@@ -4850,12 +5540,109 @@ var Sandbox = class Sandbox extends Container {
4850
5540
  });
4851
5541
  }
4852
5542
  }
5543
+ getR2EgressParams() {
5544
+ const buckets = {};
5545
+ for (const [, m] of this.activeMounts) if (m.mountType === "r2-egress") buckets[m.bucket] = {
5546
+ prefix: m.prefix,
5547
+ readOnly: m.readOnly
5548
+ };
5549
+ return { buckets };
5550
+ }
5551
+ validateR2EgressS3fsOptions(options) {
5552
+ if (!options) return;
5553
+ const protectedOptions = new Set(["passwd_file", "url"]);
5554
+ for (const option of options) {
5555
+ const [key] = option.split("=");
5556
+ if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for R2 binding mounts`);
5557
+ }
5558
+ }
5559
+ /**
5560
+ * Credential-less R2 mount: egress interception routes s3fs requests to the
5561
+ * R2 binding. No S3 credentials are needed in the container or Worker env.
5562
+ */
5563
+ async mountBucketR2Egress(bucket, mountPath, options) {
5564
+ const mountStartTime = Date.now();
5565
+ const prefix = options.prefix;
5566
+ let mountOutcome = "error";
5567
+ let mountError;
5568
+ try {
5569
+ validateBucketBindingName(bucket, mountPath);
5570
+ this.validateMountPath(mountPath);
5571
+ this.validateR2EgressS3fsOptions(options.s3fsOptions);
5572
+ for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
5573
+ if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.prefix !== prefix) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different prefix. Mount the same binding only once, or use the same prefix for additional mounts.`);
5574
+ if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.readOnly !== (options.readOnly ?? false)) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different readOnly setting. Mount the same binding only once, or use the same readOnly value for additional mounts.`);
5575
+ }
5576
+ const passwordFilePath = this.generatePasswordFilePath();
5577
+ await this.createPasswordFile(passwordFilePath, bucket, {
5578
+ accessKeyId: "x",
5579
+ secretAccessKey: "x"
5580
+ });
5581
+ const mountInfo = {
5582
+ mountType: "r2-egress",
5583
+ bucket,
5584
+ mountPath,
5585
+ passwordFilePath,
5586
+ mounted: false,
5587
+ prefix,
5588
+ readOnly: options.readOnly ?? false
5589
+ };
5590
+ this.activeMounts.set(mountPath, mountInfo);
5591
+ await this.configureR2EgressOutbound(this.getR2EgressParams());
5592
+ await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
5593
+ const s3fsSource = bucket;
5594
+ const optionsStr = shellEscape(serializeS3fsOptions({
5595
+ passwd_file: passwordFilePath,
5596
+ ...R2_DEFAULT_S3FS_OPTIONS,
5597
+ ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
5598
+ use_path_request_style: true,
5599
+ url: "http://r2.internal",
5600
+ ...options.readOnly ? { ro: true } : {}
5601
+ }));
5602
+ const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
5603
+ this.logger.debug("r2-egress: running s3fs", { mountCmd });
5604
+ const result = await this.execInternal(mountCmd);
5605
+ this.logger.debug("r2-egress: s3fs exited", {
5606
+ exitCode: result.exitCode,
5607
+ stdout: result.stdout,
5608
+ stderr: result.stderr
5609
+ });
5610
+ if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
5611
+ const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
5612
+ this.logger.debug("r2-egress: mountpoint check", {
5613
+ stdout: mountpointCheck.stdout.trim(),
5614
+ exitCode: mountpointCheck.exitCode
5615
+ });
5616
+ if (mountpointCheck.stdout.trim() !== "FUSE_MOUNTED") throw new S3FSMountError(`s3fs exited 0 but mount was not established at ${mountPath}`);
5617
+ mountInfo.mounted = true;
5618
+ mountOutcome = "success";
5619
+ } catch (error) {
5620
+ mountError = error instanceof Error ? error : new Error(String(error));
5621
+ const failedMount = this.activeMounts.get(mountPath);
5622
+ this.activeMounts.delete(mountPath);
5623
+ if (failedMount?.mountType === "r2-egress") await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
5624
+ const remainingParams = this.getR2EgressParams();
5625
+ await this.configureR2EgressOutbound(remainingParams).catch(() => {});
5626
+ throw error;
5627
+ } finally {
5628
+ logCanonicalEvent(this.logger, {
5629
+ event: "bucket.mount",
5630
+ outcome: mountOutcome,
5631
+ durationMs: Date.now() - mountStartTime,
5632
+ bucket,
5633
+ mountPath,
5634
+ provider: "r2",
5635
+ prefix,
5636
+ error: mountError
5637
+ });
5638
+ }
5639
+ }
4853
5640
  /**
4854
5641
  * Production mount: S3FS-FUSE inside the container
4855
5642
  */
4856
5643
  async mountBucketFuse(bucket, mountPath, options) {
4857
5644
  const mountStartTime = Date.now();
4858
- const prefix = options.prefix || void 0;
5645
+ const prefix = options.prefix;
4859
5646
  let mountOutcome = "error";
4860
5647
  let mountError;
4861
5648
  let passwordFilePath;
@@ -4946,6 +5733,7 @@ var Sandbox = class Sandbox extends Container {
4946
5733
  }
4947
5734
  mountInfo.mounted = false;
4948
5735
  this.activeMounts.delete(mountPath);
5736
+ if (mountInfo.mountType === "r2-egress") await this.configureR2EgressOutbound(this.getR2EgressParams());
4949
5737
  try {
4950
5738
  const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
4951
5739
  if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
@@ -4978,7 +5766,14 @@ var Sandbox = class Sandbox extends Container {
4978
5766
  }
4979
5767
  }
4980
5768
  /**
4981
- * Validate mount options
5769
+ * Shared validation for mount path (absolute, not already in use).
5770
+ */
5771
+ validateMountPath(mountPath) {
5772
+ if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
5773
+ 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.`);
5774
+ }
5775
+ /**
5776
+ * Validate mount options for remote (FUSE) mounts
4982
5777
  */
4983
5778
  validateMountOptions(bucket, mountPath, options) {
4984
5779
  try {
@@ -4987,8 +5782,7 @@ var Sandbox = class Sandbox extends Container {
4987
5782
  throw new InvalidMountConfigError(`Invalid endpoint URL: "${options.endpoint}". Must be a valid HTTP(S) URL.`);
4988
5783
  }
4989
5784
  validateBucketName(bucket, mountPath);
4990
- if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
4991
- 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.`);
5785
+ this.validateMountPath(mountPath);
4992
5786
  }
4993
5787
  /**
4994
5788
  * Generate unique password file path for s3fs credentials
@@ -5002,7 +5796,7 @@ var Sandbox = class Sandbox extends Container {
5002
5796
  */
5003
5797
  async createPasswordFile(passwordFilePath, bucket, credentials) {
5004
5798
  const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
5005
- await this.writeFile(passwordFilePath, content);
5799
+ await this.client.files.writeFile(passwordFilePath, content, DISABLE_SESSION_TOKEN);
5006
5800
  await this.execInternal(`chmod 0600 ${shellEscape(passwordFilePath)}`);
5007
5801
  }
5008
5802
  /**
@@ -5048,6 +5842,13 @@ var Sandbox = class Sandbox extends Container {
5048
5842
  if (result.exitCode === 2) throw new S3FSMountError(`S3FS mount failed: ${detail || "Unknown error"}`);
5049
5843
  throw new S3FSMountError(`S3FS mount failed: FUSE filesystem never appeared at ${mountPath}. ${detail ? `s3fs log: ${detail}` : "No s3fs log output captured. The s3fs daemon may have exited before writing logs."}`);
5050
5844
  }
5845
+ async unmountTrackedFuseMount(mountPath, mountInfo) {
5846
+ if (!mountInfo.mounted) return;
5847
+ this.logger.debug(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
5848
+ const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
5849
+ if (result.exitCode !== 0) throw new Error(`fusermount -u failed (exit ${result.exitCode}): ${result.stderr || "unknown error"}`);
5850
+ mountInfo.mounted = false;
5851
+ }
5051
5852
  /**
5052
5853
  * In-flight `destroy()` promise. While set, concurrent callers coalesce
5053
5854
  * onto the same teardown instead of triggering a second one. Cleared when
@@ -5109,10 +5910,8 @@ var Sandbox = class Sandbox extends Container {
5109
5910
  this.logger.warn(`Failed to stop local sync for ${mountPath}: ${errorMsg}`);
5110
5911
  }
5111
5912
  else {
5112
- if (mountInfo.mounted) try {
5113
- this.logger.debug(`Unmounting bucket ${mountInfo.bucket} from ${mountPath}`);
5114
- await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
5115
- mountInfo.mounted = false;
5913
+ try {
5914
+ await this.unmountTrackedFuseMount(mountPath, mountInfo);
5116
5915
  } catch (error) {
5117
5916
  mountFailures++;
5118
5917
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -5122,6 +5921,7 @@ var Sandbox = class Sandbox extends Container {
5122
5921
  }
5123
5922
  }
5124
5923
  await this.ctx.storage.delete("portTokens");
5924
+ await this.ctx.storage.delete("tunnels");
5125
5925
  this.client.disconnect();
5126
5926
  outcome = "success";
5127
5927
  await super.destroy();
@@ -5149,6 +5949,11 @@ var Sandbox = class Sandbox extends Container {
5149
5949
  } catch (error) {
5150
5950
  this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
5151
5951
  }
5952
+ try {
5953
+ await this.ctx.storage.delete("tunnels");
5954
+ } catch (error) {
5955
+ this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
5956
+ }
5152
5957
  }
5153
5958
  /**
5154
5959
  * Re-expose ports on the container runtime using tokens persisted in DO
@@ -5169,8 +5974,7 @@ var Sandbox = class Sandbox extends Container {
5169
5974
  let restored = 0;
5170
5975
  let skipped = 0;
5171
5976
  let failed = 0;
5172
- const sessionId = await this.ensureDefaultSession();
5173
- const exposedSet = await this.client.ports.getExposedPorts(sessionId).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
5977
+ const exposedSet = await this.client.ports.getExposedPorts(DISABLE_SESSION_TOKEN).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
5174
5978
  this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
5175
5979
  return /* @__PURE__ */ new Set();
5176
5980
  });
@@ -5186,7 +5990,7 @@ var Sandbox = class Sandbox extends Container {
5186
5990
  continue;
5187
5991
  }
5188
5992
  try {
5189
- await this.client.ports.exposePort(port, sessionId, entry.name);
5993
+ await this.client.ports.exposePort(port, DISABLE_SESSION_TOKEN, entry.name);
5190
5994
  restored++;
5191
5995
  } catch (error) {
5192
5996
  failed++;
@@ -5251,7 +6055,10 @@ var Sandbox = class Sandbox extends Container {
5251
6055
  this.defaultSession = null;
5252
6056
  this.defaultSessionInit = null;
5253
6057
  this.client.disconnect();
6058
+ let hadR2EgressMount = false;
5254
6059
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
6060
+ else if (m.mountType === "r2-egress") hadR2EgressMount = true;
6061
+ if (hadR2EgressMount) await this.configureR2EgressOutbound({ buckets: {} }).catch(() => {});
5255
6062
  this.activeMounts.clear();
5256
6063
  await this.ctx.storage.delete("defaultSession");
5257
6064
  }
@@ -5566,10 +6373,78 @@ var Sandbox = class Sandbox extends Container {
5566
6373
  if (containerPlacementId === void 0) return;
5567
6374
  await this.ctx.storage.put("containerPlacementId", containerPlacementId);
5568
6375
  }
6376
+ async resolveExecution(explicitSessionId) {
6377
+ if (explicitSessionId !== void 0) {
6378
+ this.validateExplicitSessionId(explicitSessionId);
6379
+ if (explicitSessionId === DISABLE_SESSION_TOKEN) return { kind: "sessionless" };
6380
+ return {
6381
+ kind: "session",
6382
+ sessionId: explicitSessionId
6383
+ };
6384
+ }
6385
+ return {
6386
+ kind: "session",
6387
+ sessionId: await this.ensureDefaultSession()
6388
+ };
6389
+ }
6390
+ validateExplicitSessionId(sessionId) {
6391
+ if (sessionId.trim().length === 0) throw new Error("sessionId must not be empty or whitespace");
6392
+ }
6393
+ serializeExecutionContext(context) {
6394
+ if (context.kind === "sessionless") return DISABLE_SESSION_TOKEN;
6395
+ return context.sessionId;
6396
+ }
6397
+ getPublicExecutionSessionId(sessionId) {
6398
+ return sessionId === DISABLE_SESSION_TOKEN ? void 0 : sessionId;
6399
+ }
6400
+ /**
6401
+ * Resolves the session ID to annotate returned Process objects.
6402
+ *
6403
+ * Unlike `resolveExecution`, this is synchronous and never creates a
6404
+ * session. When the default session hasn't been established yet, it returns
6405
+ * `undefined` rather than triggering session creation. The resolved value is
6406
+ * only used to populate `Process.sessionId` on the returned object — it is
6407
+ * never sent to the container API.
6408
+ */
6409
+ getProcessSessionBinding(explicitSessionId) {
6410
+ if (explicitSessionId !== void 0) {
6411
+ this.validateExplicitSessionId(explicitSessionId);
6412
+ if (explicitSessionId === DISABLE_SESSION_TOKEN) return;
6413
+ return explicitSessionId;
6414
+ }
6415
+ return this.defaultSession ?? void 0;
6416
+ }
6417
+ resolveExecutionEnv(sessionId, env$1) {
6418
+ if (sessionId === DISABLE_SESSION_TOKEN) {
6419
+ const mergedEnv = filterEnvVars({
6420
+ ...this.envVars,
6421
+ ...env$1 ?? {}
6422
+ });
6423
+ return Object.keys(mergedEnv).length > 0 ? mergedEnv : void 0;
6424
+ }
6425
+ if (env$1 === void 0) return;
6426
+ const filteredEnv = filterEnvVars(env$1);
6427
+ return Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
6428
+ }
6429
+ buildExecutionRequestOptions(sessionId, options) {
6430
+ const env$1 = this.resolveExecutionEnv(sessionId, options?.env);
6431
+ if (options?.timeout === void 0 && env$1 === void 0 && options?.cwd === void 0 && options?.origin === void 0) return;
6432
+ return {
6433
+ ...options?.timeout !== void 0 && { timeoutMs: options.timeout },
6434
+ ...env$1 !== void 0 && { env: env$1 },
6435
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
6436
+ ...options?.origin !== void 0 && { origin: options.origin }
6437
+ };
6438
+ }
5569
6439
  async exec(command, options) {
5570
- const session = await this.ensureDefaultSession();
6440
+ const context = await this.resolveExecution();
6441
+ const session = this.serializeExecutionContext(context);
5571
6442
  return this.execWithSession(command, session, options);
5572
6443
  }
6444
+ async execWithSessionToken(command, sessionId, options) {
6445
+ this.validateExplicitSessionId(sessionId);
6446
+ return this.execWithSession(command, sessionId, options);
6447
+ }
5573
6448
  /**
5574
6449
  * Execute an infrastructure command (backup, mount, env setup, etc.)
5575
6450
  * tagged with origin: 'internal' so logging demotes it to debug level.
@@ -5592,15 +6467,11 @@ var Sandbox = class Sandbox extends Container {
5592
6467
  let result;
5593
6468
  if (options?.stream && options?.onOutput) result = await this.executeWithStreaming(command, sessionId, options, startTime, timestamp);
5594
6469
  else {
5595
- const commandOptions = options && (options.timeout !== void 0 || options.env !== void 0 || options.cwd !== void 0 || options.origin !== void 0) ? {
5596
- timeoutMs: options.timeout,
5597
- env: options.env,
5598
- cwd: options.cwd,
5599
- origin: options.origin
5600
- } : void 0;
6470
+ const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
5601
6471
  const response = await this.client.commands.execute(command, sessionId, commandOptions);
5602
6472
  const duration = Date.now() - startTime;
5603
- result = this.mapExecuteResponseToExecResult(response, duration, sessionId);
6473
+ const publicSessionId = this.getPublicExecutionSessionId(sessionId);
6474
+ result = this.mapExecuteResponseToExecResult(response, duration, publicSessionId);
5604
6475
  }
5605
6476
  execOutcome = {
5606
6477
  exitCode: result.exitCode,
@@ -5619,7 +6490,7 @@ var Sandbox = class Sandbox extends Container {
5619
6490
  command,
5620
6491
  exitCode: execOutcome?.exitCode,
5621
6492
  durationMs: Date.now() - startTime,
5622
- sessionId,
6493
+ sessionId: this.getPublicExecutionSessionId(sessionId),
5623
6494
  origin: options?.origin ?? "user",
5624
6495
  error: execError ?? void 0,
5625
6496
  errorMessage: execError?.message
@@ -5630,12 +6501,8 @@ var Sandbox = class Sandbox extends Container {
5630
6501
  let stdout = "";
5631
6502
  let stderr = "";
5632
6503
  try {
5633
- const stream = await this.client.commands.executeStream(command, sessionId, {
5634
- timeoutMs: options.timeout,
5635
- env: options.env,
5636
- cwd: options.cwd,
5637
- origin: options.origin
5638
- });
6504
+ const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
6505
+ const stream = await this.client.commands.executeStream(command, sessionId, commandOptions);
5639
6506
  for await (const event of parseSSEStream(stream)) {
5640
6507
  if (options.signal?.aborted) throw new Error("Operation was aborted");
5641
6508
  switch (event.type) {
@@ -5657,7 +6524,7 @@ var Sandbox = class Sandbox extends Container {
5657
6524
  command,
5658
6525
  duration,
5659
6526
  timestamp,
5660
- sessionId
6527
+ sessionId: this.getPublicExecutionSessionId(sessionId)
5661
6528
  };
5662
6529
  }
5663
6530
  case "error": throw new Error(event.data || "Command execution failed");
@@ -5933,12 +6800,16 @@ var Sandbox = class Sandbox extends Container {
5933
6800
  }
5934
6801
  async startProcess(command, options, sessionId) {
5935
6802
  try {
5936
- const session = sessionId ?? await this.ensureDefaultSession();
6803
+ const execution = await this.resolveExecution(sessionId);
6804
+ const session = this.serializeExecutionContext(execution);
6805
+ const processSession = this.getProcessSessionBinding(session);
5937
6806
  const requestOptions = {
6807
+ ...this.buildExecutionRequestOptions(session, {
6808
+ timeout: options?.timeout,
6809
+ env: options?.env,
6810
+ cwd: options?.cwd
6811
+ }),
5938
6812
  ...options?.processId !== void 0 && { processId: options.processId },
5939
- ...options?.timeout !== void 0 && { timeoutMs: options.timeout },
5940
- ...options?.env !== void 0 && { env: filterEnvVars(options.env) },
5941
- ...options?.cwd !== void 0 && { cwd: options.cwd },
5942
6813
  ...options?.encoding !== void 0 && { encoding: options.encoding },
5943
6814
  ...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
5944
6815
  };
@@ -5951,7 +6822,7 @@ var Sandbox = class Sandbox extends Container {
5951
6822
  startTime: /* @__PURE__ */ new Date(),
5952
6823
  endTime: void 0,
5953
6824
  exitCode: void 0
5954
- }, session);
6825
+ }, processSession);
5955
6826
  if (options?.onStart) options.onStart(processObj);
5956
6827
  if (options?.onOutput || options?.onExit) this.startProcessCallbackStream(response.processId, options).catch(() => {});
5957
6828
  return processObj;
@@ -5979,13 +6850,14 @@ var Sandbox = class Sandbox extends Container {
5979
6850
  if (options.onExit) options.onExit(event.exitCode ?? null);
5980
6851
  return;
5981
6852
  }
6853
+ throw new Error("Stream ended without completion event");
5982
6854
  } catch (error) {
5983
6855
  if (options.onError && error instanceof Error) options.onError(error);
5984
6856
  this.logger.error("Background process streaming failed", error instanceof Error ? error : new Error(String(error)), { processId });
5985
6857
  }
5986
6858
  }
5987
6859
  async listProcesses(sessionId) {
5988
- const session = sessionId ?? await this.ensureDefaultSession();
6860
+ const session = this.getProcessSessionBinding(sessionId);
5989
6861
  return (await this.client.processes.listProcesses()).processes.map((processData) => this.createProcessFromDTO({
5990
6862
  id: processData.id,
5991
6863
  pid: processData.pid,
@@ -5997,19 +6869,24 @@ var Sandbox = class Sandbox extends Container {
5997
6869
  }, session));
5998
6870
  }
5999
6871
  async getProcess(id, sessionId) {
6000
- const session = sessionId ?? await this.ensureDefaultSession();
6001
- const response = await this.client.processes.getProcess(id);
6002
- if (!response.process) return null;
6003
- const processData = response.process;
6004
- return this.createProcessFromDTO({
6005
- id: processData.id,
6006
- pid: processData.pid,
6007
- command: processData.command,
6008
- status: processData.status,
6009
- startTime: processData.startTime,
6010
- endTime: processData.endTime,
6011
- exitCode: processData.exitCode
6012
- }, session);
6872
+ const session = this.getProcessSessionBinding(sessionId);
6873
+ try {
6874
+ const response = await this.client.processes.getProcess(id);
6875
+ if (!response.process) return null;
6876
+ const processData = response.process;
6877
+ return this.createProcessFromDTO({
6878
+ id: processData.id,
6879
+ pid: processData.pid,
6880
+ command: processData.command,
6881
+ status: processData.status,
6882
+ startTime: processData.startTime,
6883
+ endTime: processData.endTime,
6884
+ exitCode: processData.exitCode
6885
+ }, session);
6886
+ } catch (error) {
6887
+ if (error instanceof ProcessNotFoundError) return null;
6888
+ throw error;
6889
+ }
6013
6890
  }
6014
6891
  async killProcess(id, signal, sessionId) {
6015
6892
  await this.client.processes.killProcess(id);
@@ -6030,23 +6907,29 @@ var Sandbox = class Sandbox extends Container {
6030
6907
  }
6031
6908
  async execStream(command, options) {
6032
6909
  if (options?.signal?.aborted) throw new Error("Operation was aborted");
6033
- const session = await this.ensureDefaultSession();
6034
- return this.client.commands.executeStream(command, session, {
6035
- timeoutMs: options?.timeout,
6910
+ const context = await this.resolveExecution(options?.sessionId);
6911
+ const session = this.serializeExecutionContext(context);
6912
+ const executionOptions = this.buildExecutionRequestOptions(session, {
6913
+ timeout: options?.timeout,
6036
6914
  env: options?.env,
6037
6915
  cwd: options?.cwd
6038
6916
  });
6917
+ return this.client.commands.executeStream(command, session, executionOptions);
6918
+ }
6919
+ async execStreamWithSessionToken(command, sessionId, options) {
6920
+ this.validateExplicitSessionId(sessionId);
6921
+ return this.execStreamWithSession(command, sessionId, options);
6039
6922
  }
6040
6923
  /**
6041
6924
  * Internal session-aware execStream implementation
6042
6925
  */
6043
6926
  async execStreamWithSession(command, sessionId, options) {
6044
6927
  if (options?.signal?.aborted) throw new Error("Operation was aborted");
6045
- return this.client.commands.executeStream(command, sessionId, {
6046
- timeoutMs: options?.timeout,
6928
+ return this.client.commands.executeStream(command, sessionId, this.buildExecutionRequestOptions(sessionId, {
6929
+ timeout: options?.timeout,
6047
6930
  env: options?.env,
6048
6931
  cwd: options?.cwd
6049
- });
6932
+ }));
6050
6933
  }
6051
6934
  /**
6052
6935
  * Stream logs from a background process as a ReadableStream.
@@ -6056,7 +6939,8 @@ var Sandbox = class Sandbox extends Container {
6056
6939
  return this.client.processes.streamProcessLogs(processId);
6057
6940
  }
6058
6941
  async gitCheckout(repoUrl, options) {
6059
- const session = options?.sessionId ?? await this.ensureDefaultSession();
6942
+ const execution = await this.resolveExecution(options?.sessionId);
6943
+ const session = this.serializeExecutionContext(execution);
6060
6944
  return this.client.git.checkout(repoUrl, session, {
6061
6945
  branch: options?.branch,
6062
6946
  targetDir: options?.targetDir,
@@ -6065,28 +6949,34 @@ var Sandbox = class Sandbox extends Container {
6065
6949
  });
6066
6950
  }
6067
6951
  async mkdir(path$1, options = {}) {
6068
- const session = options.sessionId ?? await this.ensureDefaultSession();
6952
+ const execution = await this.resolveExecution(options.sessionId);
6953
+ const session = this.serializeExecutionContext(execution);
6069
6954
  return this.client.files.mkdir(path$1, session, { recursive: options.recursive });
6070
6955
  }
6071
6956
  async writeFile(path$1, content, options = {}) {
6072
- const session = options.sessionId ?? await this.ensureDefaultSession();
6957
+ const execution = await this.resolveExecution(options.sessionId);
6958
+ const session = this.serializeExecutionContext(execution);
6073
6959
  if (content instanceof ReadableStream) return this.client.files.writeFileStream(path$1, content, session);
6074
6960
  return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
6075
6961
  }
6076
6962
  async deleteFile(path$1, sessionId) {
6077
- const session = sessionId ?? await this.ensureDefaultSession();
6963
+ const execution = await this.resolveExecution(sessionId);
6964
+ const session = this.serializeExecutionContext(execution);
6078
6965
  return this.client.files.deleteFile(path$1, session);
6079
6966
  }
6080
6967
  async renameFile(oldPath, newPath, sessionId) {
6081
- const session = sessionId ?? await this.ensureDefaultSession();
6968
+ const execution = await this.resolveExecution(sessionId);
6969
+ const session = this.serializeExecutionContext(execution);
6082
6970
  return this.client.files.renameFile(oldPath, newPath, session);
6083
6971
  }
6084
6972
  async moveFile(sourcePath, destinationPath, sessionId) {
6085
- const session = sessionId ?? await this.ensureDefaultSession();
6973
+ const execution = await this.resolveExecution(sessionId);
6974
+ const session = this.serializeExecutionContext(execution);
6086
6975
  return this.client.files.moveFile(sourcePath, destinationPath, session);
6087
6976
  }
6088
6977
  async readFile(path$1, options = {}) {
6089
- const session = options.sessionId ?? await this.ensureDefaultSession();
6978
+ const execution = await this.resolveExecution(options.sessionId);
6979
+ const session = this.serializeExecutionContext(execution);
6090
6980
  if (options.encoding === "none") return this.client.files.readFile(path$1, session, { encoding: "none" });
6091
6981
  return this.client.files.readFile(path$1, session, { encoding: options.encoding });
6092
6982
  }
@@ -6097,15 +6987,18 @@ var Sandbox = class Sandbox extends Container {
6097
6987
  * @param options - Optional session ID
6098
6988
  */
6099
6989
  async readFileStream(path$1, options = {}) {
6100
- const session = options.sessionId ?? await this.ensureDefaultSession();
6990
+ const execution = await this.resolveExecution(options.sessionId);
6991
+ const session = this.serializeExecutionContext(execution);
6101
6992
  return this.client.files.readFileStream(path$1, session);
6102
6993
  }
6103
6994
  async listFiles(path$1, options) {
6104
- const session = await this.ensureDefaultSession();
6995
+ const context = await this.resolveExecution(options?.sessionId);
6996
+ const session = this.serializeExecutionContext(context);
6105
6997
  return this.client.files.listFiles(path$1, session, options);
6106
6998
  }
6107
6999
  async exists(path$1, sessionId) {
6108
- const session = sessionId ?? await this.ensureDefaultSession();
7000
+ const execution = await this.resolveExecution(sessionId);
7001
+ const session = this.serializeExecutionContext(execution);
6109
7002
  return this.client.files.exists(path$1, session);
6110
7003
  }
6111
7004
  /**
@@ -6156,7 +7049,8 @@ var Sandbox = class Sandbox extends Container {
6156
7049
  * @param options - Watch options
6157
7050
  */
6158
7051
  async watch(path$1, options = {}) {
6159
- const sessionId = options.sessionId ?? await this.ensureDefaultSession();
7052
+ const execution = await this.resolveExecution(options.sessionId);
7053
+ const sessionId = this.serializeExecutionContext(execution);
6160
7054
  return this.client.watch.watch({
6161
7055
  path: path$1,
6162
7056
  recursive: options.recursive,
@@ -6176,7 +7070,8 @@ var Sandbox = class Sandbox extends Container {
6176
7070
  * @param options - Change-check options
6177
7071
  */
6178
7072
  async checkChanges(path$1, options = {}) {
6179
- const sessionId = options.sessionId ?? await this.ensureDefaultSession();
7073
+ const execution = await this.resolveExecution(options.sessionId);
7074
+ const sessionId = this.serializeExecutionContext(execution);
6180
7075
  return this.client.watch.checkChanges({
6181
7076
  path: path$1,
6182
7077
  recursive: options.recursive,
@@ -6238,7 +7133,7 @@ var Sandbox = class Sandbox extends Container {
6238
7133
  const tokens = await this.readPortTokens();
6239
7134
  const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
6240
7135
  if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
6241
- const sessionId = await this.ensureDefaultSession();
7136
+ const sessionId = this.serializeExecutionContext(await this.resolveExecution());
6242
7137
  await this.client.ports.exposePort(port, sessionId, options?.name);
6243
7138
  tokens[port.toString()] = {
6244
7139
  token,
@@ -6278,7 +7173,7 @@ var Sandbox = class Sandbox extends Container {
6278
7173
  delete tokens[port.toString()];
6279
7174
  await this.ctx.storage.put("portTokens", tokens);
6280
7175
  }
6281
- const sessionId = await this.ensureDefaultSession();
7176
+ const sessionId = this.serializeExecutionContext(await this.resolveExecution());
6282
7177
  try {
6283
7178
  await this.client.ports.unexposePort(port, sessionId);
6284
7179
  } catch (error) {
@@ -6299,7 +7194,7 @@ var Sandbox = class Sandbox extends Container {
6299
7194
  }
6300
7195
  }
6301
7196
  async getExposedPorts(hostname) {
6302
- const sessionId = await this.ensureDefaultSession();
7197
+ const sessionId = this.serializeExecutionContext(await this.resolveExecution());
6303
7198
  const response = await this.client.ports.getExposedPorts(sessionId);
6304
7199
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
6305
7200
  const tokens = await this.readPortTokens();
@@ -6316,9 +7211,48 @@ var Sandbox = class Sandbox extends Container {
6316
7211
  }];
6317
7212
  });
6318
7213
  }
7214
+ /**
7215
+ * Namespaced tunnel API. Quick tunnels are zero-config preview URLs
7216
+ * backed by Cloudflare's trycloudflare service.
7217
+ *
7218
+ * - `tunnels.get(port)` — idempotent. Returns the cached tunnel for
7219
+ * `port` if one exists in DO storage, otherwise spawns a fresh
7220
+ * cloudflared process and persists the record.
7221
+ * - `tunnels.list()` — records currently known to this sandbox, from
7222
+ * DO storage.
7223
+ * - `tunnels.destroy(portOrInfo)` — tear down by port number or by
7224
+ * the record returned from `get()`.
7225
+ *
7226
+ * Storage is cleared on container restart (`onStart`), so URLs do
7227
+ * not survive a container restart — the next `get(port)` call will
7228
+ * spawn a fresh tunnel with a new URL.
7229
+ *
7230
+ * Requires the RPC transport. Calling this on a route-based transport
7231
+ * throws "RPC transport required".
7232
+ */
7233
+ get tunnels() {
7234
+ this.ensureTunnelsBuilt();
7235
+ return this.tunnelsHandler;
7236
+ }
7237
+ /**
7238
+ * Lazily construct both the public tunnels handler and its sibling
7239
+ * exit-handler callback. Called from the `tunnels` getter on first
7240
+ * access and on every access after a transport swap clears both
7241
+ * fields.
7242
+ */
7243
+ ensureTunnelsBuilt() {
7244
+ if (this.tunnelsHandler) return;
7245
+ const built = createTunnelsHandler({
7246
+ client: this.client,
7247
+ storage: this.ctx.storage,
7248
+ logger: this.logger
7249
+ });
7250
+ this.tunnelsHandler = built.tunnels;
7251
+ this.tunnelExitHandler = built.handleTunnelExit;
7252
+ }
6319
7253
  async isPortExposed(port) {
6320
7254
  try {
6321
- const sessionId = await this.ensureDefaultSession();
7255
+ const sessionId = this.serializeExecutionContext(await this.resolveExecution());
6322
7256
  return (await this.client.ports.getExposedPorts(sessionId)).ports.some((exposedPort) => exposedPort.port === port);
6323
7257
  } catch (error) {
6324
7258
  this.logger.error("Error checking if port is exposed", error instanceof Error ? error : new Error(String(error)), { port });
@@ -6378,6 +7312,7 @@ var Sandbox = class Sandbox extends Container {
6378
7312
  */
6379
7313
  async createSession(options) {
6380
7314
  const sessionId = options?.id || `session-${Date.now()}`;
7315
+ if (sessionId === DISABLE_SESSION_TOKEN) throw new Error(`Session ID '${DISABLE_SESSION_TOKEN}' is reserved for internal use`);
6381
7316
  const filteredEnv = filterEnvVars({
6382
7317
  ...this.envVars,
6383
7318
  ...options?.env ?? {}
@@ -7585,8 +8520,24 @@ var Sandbox = class Sandbox extends Container {
7585
8520
  });
7586
8521
  }
7587
8522
  }
8523
+ async configureR2EgressOutbound(params) {
8524
+ const ctx = this.ctx;
8525
+ if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
8526
+ if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
8527
+ const fetcher = ctx.exports.ContainerProxy({ props: {
8528
+ enableInternet: this.enableInternet,
8529
+ containerId: this.ctx.id.toString(),
8530
+ className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
8531
+ outboundByHostOverrides: { "r2.internal": {
8532
+ method: "r2EgressMount",
8533
+ params
8534
+ } }
8535
+ } });
8536
+ if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
8537
+ await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
8538
+ }
7588
8539
  };
7589
8540
 
7590
8541
  //#endregion
7591
8542
  export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P, RPCTransportError as R, DesktopClient as S, BackupCreateError as T, UtilityClient as _, BucketMountError as a, GitClient as b, MissingCredentialsError as c, parseSSEStream as d, responseToAsyncIterable as f, SandboxClient as g, streamFile as h, proxyTerminal as i, DesktopNotStartedError as j, DesktopInvalidCoordinatesError as k, S3FSMountError as l, collectFile as m, getSandbox as n, BucketUnmountError as o, CodeInterpreter as p, proxyToSandbox as r, InvalidMountConfigError as s, Sandbox as t, asyncIterableToSSEStream as u, ProcessClient as v, BackupClient as w, FileClient as x, PortClient as y, SessionTerminatedError as z };
7592
- //# sourceMappingURL=sandbox-uC1vzWtG.js.map
8543
+ //# sourceMappingURL=sandbox-B-MUmsli.js.map