@cloudflare/sandbox 0.10.2 → 0.11.0

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,5 +1,5 @@
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-8Hvune8K.js";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-COsTRno_.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
5
  import { RpcSession, RpcTarget } from "capnweb";
@@ -231,7 +231,7 @@ var SessionTerminatedError = class extends SandboxError {
231
231
  }
232
232
  };
233
233
  /**
234
- * Error thrown when a port is already exposed
234
+ * Compatibility error for legacy port exposure registry responses.
235
235
  */
236
236
  var PortAlreadyExposedError = class extends SandboxError {
237
237
  constructor(errorResponse) {
@@ -246,7 +246,7 @@ var PortAlreadyExposedError = class extends SandboxError {
246
246
  }
247
247
  };
248
248
  /**
249
- * Error thrown when a port is not exposed
249
+ * Compatibility error for legacy port exposure registry responses.
250
250
  */
251
251
  var PortNotExposedError = class extends SandboxError {
252
252
  constructor(errorResponse) {
@@ -2365,40 +2365,9 @@ var InterpreterClient = class extends BaseHttpClient {
2365
2365
  //#endregion
2366
2366
  //#region src/clients/port-client.ts
2367
2367
  /**
2368
- * Client for port management and preview URL operations
2368
+ * Client for port readiness operations.
2369
2369
  */
2370
2370
  var PortClient = class extends BaseHttpClient {
2371
- /**
2372
- * Expose a port and get a preview URL
2373
- * @param port - Port number to expose
2374
- * @param sessionId - The session ID for this operation
2375
- * @param name - Optional name for the port
2376
- */
2377
- async exposePort(port, sessionId, name) {
2378
- const data = {
2379
- port,
2380
- sessionId,
2381
- name
2382
- };
2383
- return await this.post("/api/expose-port", data);
2384
- }
2385
- /**
2386
- * Unexpose a port and remove its preview URL
2387
- * @param port - Port number to unexpose
2388
- * @param sessionId - The session ID for this operation
2389
- */
2390
- async unexposePort(port, sessionId) {
2391
- const url = `/api/exposed-ports/${port}?session=${encodeURIComponent(sessionId)}`;
2392
- return await this.delete(url);
2393
- }
2394
- /**
2395
- * Get all currently exposed ports
2396
- * @param sessionId - The session ID for this operation
2397
- */
2398
- async getExposedPorts(sessionId) {
2399
- const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`;
2400
- return await this.get(url);
2401
- }
2402
2371
  /**
2403
2372
  * Watch a port for readiness via SSE stream
2404
2373
  * @param request - Port watch configuration
@@ -2765,6 +2734,10 @@ function normalizeBackupExcludePattern(pattern) {
2765
2734
  return normalized;
2766
2735
  }
2767
2736
 
2737
+ //#endregion
2738
+ //#region ../shared/src/internal.ts
2739
+ const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
2740
+
2768
2741
  //#endregion
2769
2742
  //#region src/container-control/connection.ts
2770
2743
  const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
@@ -3057,12 +3030,33 @@ const IDLE_EXPORT_THRESHOLD = 1;
3057
3030
  /**
3058
3031
  * Translate a capnweb-propagated error into a typed SandboxError.
3059
3032
  *
3060
- * capnweb only preserves `error.name` and `error.message` across the wire.
3061
- * The container encodes the full error as a JSON object in the message
3062
- * string: `{"code":"...","message":"...","context":{...}}`.
3033
+ * Two wire formats are supported for backward compatibility with older
3034
+ * container images:
3035
+ *
3036
+ * 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
3037
+ * `ServiceError`-shaped object with own enumerable `code` and `details`
3038
+ * properties. capnweb walks `Object.keys()` and reconstructs those fields
3039
+ * on the SDK side.
3040
+ * 2. Legacy JSON-encoded message. Older containers encoded the structured
3041
+ * payload as a JSON string in `error.message`.
3042
+ *
3043
+ * The JSON-fallback branch can be removed once all older container images are
3044
+ * no longer in service.
3063
3045
  */
3064
3046
  function translateRPCError(error) {
3065
3047
  if (error instanceof Error) {
3048
+ const propagated = error;
3049
+ if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
3050
+ const code = propagated.code;
3051
+ const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
3052
+ throw createErrorFromResponse({
3053
+ code,
3054
+ message: error.message,
3055
+ context,
3056
+ httpStatus: getHttpStatus(code),
3057
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3058
+ });
3059
+ }
3066
3060
  let payload;
3067
3061
  try {
3068
3062
  payload = JSON.parse(error.message);
@@ -3365,6 +3359,89 @@ var ContainerControlClient = class {
3365
3359
  }
3366
3360
  };
3367
3361
 
3362
+ //#endregion
3363
+ //#region src/current-runtime-identity.ts
3364
+ const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
3365
+ var RuntimeIdentityInactiveError = class extends Error {
3366
+ constructor() {
3367
+ super("Runtime identity is no longer active");
3368
+ this.name = "RuntimeIdentityInactiveError";
3369
+ }
3370
+ };
3371
+ var RuntimeIdentity = class {
3372
+ id;
3373
+ constructor(record) {
3374
+ this.id = record.id;
3375
+ }
3376
+ owns(record) {
3377
+ return record.runtimeIdentityID === this.id;
3378
+ }
3379
+ scope(value) {
3380
+ return {
3381
+ ...value,
3382
+ runtimeIdentityID: this.id
3383
+ };
3384
+ }
3385
+ };
3386
+ var CurrentRuntimeIdentity = class {
3387
+ /**
3388
+ * Runtime identity is stored in Durable Object storage so a reconstructed DO
3389
+ * can still recognize the live container runtime it owns. In-memory state is
3390
+ * only a cache and cannot define runtime-scoped correctness.
3391
+ */
3392
+ constructor(storage, getContainerState, isContainerRunning) {
3393
+ this.storage = storage;
3394
+ this.getContainerState = getContainerState;
3395
+ this.isContainerRunning = isContainerRunning;
3396
+ }
3397
+ async get() {
3398
+ const status = await this.getStatus();
3399
+ return status.status === "active" ? status.runtime : null;
3400
+ }
3401
+ async getStatus() {
3402
+ const state = await this.getContainerState();
3403
+ if (state.status !== "healthy") return {
3404
+ status: "inactive",
3405
+ reason: "runtime-not-healthy",
3406
+ containerStatus: state.status
3407
+ };
3408
+ if (!this.isContainerRunning()) return {
3409
+ status: "inactive",
3410
+ reason: "runtime-not-running",
3411
+ containerStatus: state.status
3412
+ };
3413
+ const runtime = await this.getStored();
3414
+ if (!runtime) return {
3415
+ status: "inactive",
3416
+ reason: "missing-runtime-id",
3417
+ containerStatus: state.status
3418
+ };
3419
+ return {
3420
+ status: "active",
3421
+ runtime,
3422
+ containerStatus: state.status
3423
+ };
3424
+ }
3425
+ async getStored(storage = this.storage) {
3426
+ const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
3427
+ return record ? new RuntimeIdentity(record) : null;
3428
+ }
3429
+ async markStarted() {
3430
+ const record = { id: crypto.randomUUID() };
3431
+ await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
3432
+ return new RuntimeIdentity(record);
3433
+ }
3434
+ async clear() {
3435
+ await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
3436
+ }
3437
+ async isActive(runtime) {
3438
+ return (await this.get())?.id === runtime.id;
3439
+ }
3440
+ async assertActive(runtime) {
3441
+ if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
3442
+ }
3443
+ };
3444
+
3368
3445
  //#endregion
3369
3446
  //#region src/file-stream.ts
3370
3447
  /**
@@ -3557,6 +3634,32 @@ function validateLanguage(language) {
3557
3634
  const normalized = language.toLowerCase();
3558
3635
  if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
3559
3636
  }
3637
+ /**
3638
+ * Validates a single DNS label for use as a Cloudflare Tunnel hostname.
3639
+ *
3640
+ * Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
3641
+ * input client-side before any network call. Whether the chosen label is
3642
+ * actually available under the configured zone is left to the Cloudflare
3643
+ * API (returned as a typed error).
3644
+ *
3645
+ * Rules:
3646
+ * - 1–63 characters
3647
+ * - Lowercase letters, digits, and internal hyphens only
3648
+ * - No leading or trailing hyphen
3649
+ * - No dots — multi-label hostnames need a delegated subdomain zone or
3650
+ * Advanced Certificate Manager, which are out of scope for this
3651
+ * feature. Universal SSL only covers `<label>.<zone>`.
3652
+ *
3653
+ * Throws `SandboxSecurityError` on any violation. Designed to be called
3654
+ * before any other tunnel work so callers see a fast, deterministic
3655
+ * failure.
3656
+ */
3657
+ const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
3658
+ function validateTunnelName(name) {
3659
+ if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
3660
+ if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
3661
+ if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
3662
+ }
3560
3663
 
3561
3664
  //#endregion
3562
3665
  //#region src/interpreter.ts
@@ -4212,118 +4315,138 @@ function base64ToUint8Array(base64) {
4212
4315
  }
4213
4316
 
4214
4317
  //#endregion
4215
- //#region src/pty/proxy.ts
4216
- async function proxyTerminal(stub, sessionId, request, options) {
4217
- if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
4218
- if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
4219
- const params = new URLSearchParams({ sessionId });
4220
- if (options?.cols) params.set("cols", String(options.cols));
4221
- if (options?.rows) params.set("rows", String(options.rows));
4222
- if (options?.shell) params.set("shell", options.shell);
4223
- const ptyUrl = `http://localhost/ws/pty?${params}`;
4224
- const ptyRequest = new Request(ptyUrl, request);
4225
- return stub.fetch(switchPort(ptyRequest, 3e3));
4226
- }
4227
-
4228
- //#endregion
4229
- //#region src/request-handler.ts
4230
- async function proxyToSandbox(request, env$1) {
4231
- const logger = createLogger({
4232
- component: "sandbox-do",
4233
- traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
4234
- operation: "proxy"
4235
- });
4318
+ //#region src/preview-forwarding.ts
4319
+ async function forwardPreviewRequest(tcpPort, request, lifecycle) {
4320
+ const containerURL = request.url.replace("https:", "http:");
4321
+ const settleForward = lifecycle.beginForward();
4236
4322
  try {
4237
- const url = new URL(request.url);
4238
- const routeInfo = extractSandboxRoute(url);
4239
- if (!routeInfo) return null;
4240
- const { sandboxId, port, path: path$1, token } = routeInfo;
4241
- const sandbox = getSandbox(env$1.Sandbox, sandboxId, { normalizeId: true });
4242
- if (port !== 3e3) {
4243
- if (!await sandbox.validatePortToken(port, token)) {
4244
- logger.warn("Invalid token access blocked", {
4245
- port,
4246
- sandboxId,
4247
- path: path$1,
4248
- hostname: url.hostname,
4249
- url: request.url,
4250
- method: request.method,
4251
- userAgent: request.headers.get("User-Agent") || "unknown"
4252
- });
4253
- return new Response(JSON.stringify({
4254
- error: `Access denied: Invalid token or port not exposed`,
4255
- code: "INVALID_TOKEN"
4256
- }), {
4257
- status: 404,
4258
- headers: { "Content-Type": "application/json" }
4259
- });
4260
- }
4323
+ const response = await tcpPort.fetch(containerURL, request);
4324
+ if (response.webSocket !== null) return {
4325
+ status: "response",
4326
+ response: bridgePreviewWebSocket(response, lifecycle, settleForward)
4327
+ };
4328
+ if (response.body !== null) {
4329
+ const { readable, writable } = new TransformStream();
4330
+ response.body.pipeTo(writable).finally(settleForward).catch(() => {});
4331
+ return {
4332
+ status: "response",
4333
+ response: new Response(readable, response)
4334
+ };
4261
4335
  }
4262
- if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return await sandbox.fetch(switchPort(request, port));
4263
- let proxyUrl;
4264
- if (port !== 3e3) proxyUrl = `http://localhost:${port}${path$1}${url.search}`;
4265
- else proxyUrl = `http://localhost:3000${path$1}${url.search}`;
4266
- const headers = {
4267
- "X-Original-URL": request.url,
4268
- "X-Forwarded-Host": url.hostname,
4269
- "X-Forwarded-Proto": url.protocol.replace(":", ""),
4270
- "X-Sandbox-Name": sandboxId
4336
+ settleForward();
4337
+ return {
4338
+ status: "response",
4339
+ response
4271
4340
  };
4272
- request.headers.forEach((value, key) => {
4273
- headers[key] = value;
4274
- });
4275
- const proxyRequest = new Request(proxyUrl, {
4276
- method: request.method,
4277
- headers,
4278
- body: request.body,
4279
- duplex: "half",
4280
- redirect: "manual"
4281
- });
4282
- return await sandbox.containerFetch(proxyRequest, port);
4283
4341
  } catch (error) {
4284
- logger.error("Proxy routing error", error instanceof Error ? error : new Error(String(error)));
4285
- return new Response("Proxy routing error", { status: 500 });
4342
+ settleForward();
4343
+ if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
4344
+ throw error;
4286
4345
  }
4287
4346
  }
4288
- function extractSandboxRoute(url) {
4289
- const dotIndex = url.hostname.indexOf(".");
4290
- if (dotIndex === -1) return null;
4291
- const subdomain = url.hostname.slice(0, dotIndex);
4292
- url.hostname.slice(dotIndex + 1);
4293
- const firstHyphen = subdomain.indexOf("-");
4294
- if (firstHyphen === -1) return null;
4295
- const portStr = subdomain.slice(0, firstHyphen);
4296
- if (!/^\d{4,5}$/.test(portStr)) return null;
4297
- const port = parseInt(portStr, 10);
4298
- if (!validatePort(port)) return null;
4299
- const rest = subdomain.slice(firstHyphen + 1);
4300
- const lastHyphen = rest.lastIndexOf("-");
4301
- if (lastHyphen === -1) return null;
4302
- const sandboxId = rest.slice(0, lastHyphen);
4303
- const token = rest.slice(lastHyphen + 1);
4304
- if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) return null;
4305
- if (sandboxId.length === 0 || sandboxId.length > 63) return null;
4306
- let sanitizedSandboxId;
4307
- try {
4308
- sanitizedSandboxId = sanitizeSandboxId(sandboxId);
4309
- } catch {
4310
- return null;
4347
+ function bridgePreviewWebSocket(response, lifecycle, settleForward) {
4348
+ const containerWebSocket = response.webSocket;
4349
+ if (containerWebSocket === null) {
4350
+ settleForward();
4351
+ return response;
4311
4352
  }
4312
- return {
4313
- port,
4314
- sandboxId: sanitizedSandboxId,
4315
- path: url.pathname || "/",
4316
- token
4353
+ const [client, server] = Object.values(new WebSocketPair());
4354
+ let settled = false;
4355
+ const settle = () => {
4356
+ if (!settled) {
4357
+ settled = true;
4358
+ settleForward();
4359
+ }
4317
4360
  };
4361
+ containerWebSocket.accept();
4362
+ server.accept();
4363
+ server.addEventListener("message", async (event) => {
4364
+ lifecycle.renewActivity();
4365
+ try {
4366
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
4367
+ containerWebSocket.send(data);
4368
+ } catch {
4369
+ server.close(1011, "Failed to forward message to container");
4370
+ }
4371
+ });
4372
+ containerWebSocket.addEventListener("message", async (event) => {
4373
+ lifecycle.renewActivity();
4374
+ try {
4375
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
4376
+ server.send(data);
4377
+ } catch {
4378
+ containerWebSocket.close(1011, "Failed to forward message to client");
4379
+ }
4380
+ });
4381
+ server.addEventListener("close", (event) => {
4382
+ settle();
4383
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
4384
+ containerWebSocket.close(code, event.reason);
4385
+ });
4386
+ containerWebSocket.addEventListener("close", (event) => {
4387
+ settle();
4388
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
4389
+ server.close(code, event.reason);
4390
+ });
4391
+ server.addEventListener("error", () => {
4392
+ settle();
4393
+ containerWebSocket.close(1011, "Client WebSocket error");
4394
+ });
4395
+ containerWebSocket.addEventListener("error", () => {
4396
+ settle();
4397
+ server.close(1011, "Container WebSocket error");
4398
+ });
4399
+ return new Response(null, {
4400
+ status: response.status,
4401
+ webSocket: client,
4402
+ headers: response.headers
4403
+ });
4318
4404
  }
4405
+
4406
+ //#endregion
4407
+ //#region src/preview-proxy-protocol.ts
4408
+ /** @internal */
4409
+ const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
4410
+ /** @internal */
4411
+ const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
4412
+ /** @internal */
4413
+ const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
4414
+ /** @internal */
4415
+ const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
4416
+ /** @internal */
4417
+ const PREVIEW_PROXY_HEADERS = [
4418
+ PREVIEW_PROXY_HEADER,
4419
+ PREVIEW_PROXY_PORT_HEADER,
4420
+ PREVIEW_PROXY_TOKEN_HEADER,
4421
+ PREVIEW_PROXY_SANDBOX_ID_HEADER
4422
+ ];
4423
+
4424
+ //#endregion
4425
+ //#region src/preview-url.ts
4319
4426
  function isLocalhostPattern(hostname) {
4320
- if (hostname.startsWith("[")) if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
4321
- else return hostname === "[::1]";
4427
+ if (hostname.startsWith("[")) {
4428
+ if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
4429
+ return hostname === "[::1]";
4430
+ }
4322
4431
  if (hostname === "::1") return true;
4323
4432
  const hostPart = hostname.split(":")[0];
4324
4433
  return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
4325
4434
  }
4326
4435
 
4436
+ //#endregion
4437
+ //#region src/pty/proxy.ts
4438
+ async function proxyTerminal(stub, sessionId, request, options) {
4439
+ if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
4440
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
4441
+ const params = new URLSearchParams({ sessionId });
4442
+ if (options?.cols) params.set("cols", String(options.cols));
4443
+ if (options?.rows) params.set("rows", String(options.rows));
4444
+ if (options?.shell) params.set("shell", options.shell);
4445
+ const ptyUrl = `http://localhost/ws/pty?${params}`;
4446
+ const ptyRequest = new Request(ptyUrl, request);
4447
+ return stub.fetch(switchPort(ptyRequest, 3e3));
4448
+ }
4449
+
4327
4450
  //#endregion
4328
4451
  //#region src/storage-mount/r2-egress-handler.ts
4329
4452
  const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
@@ -4652,6 +4775,178 @@ const r2EgressHandler = async (request, env$1, ctx) => {
4652
4775
  }
4653
4776
  };
4654
4777
 
4778
+ //#endregion
4779
+ //#region src/tunnels/credentials.ts
4780
+ /**
4781
+ * Resolve a Cloudflare account id from environment with documented
4782
+ * precedence. Used by features that need to address a specific account
4783
+ * (Cloudflare Tunnel, R2 backup) to find their account id without
4784
+ * forcing every caller to set the same env var.
4785
+ *
4786
+ * Precedence (first non-empty wins):
4787
+ * 1. The feature-specific override env var (e.g. `CLOUDFLARE_TUNNEL_ACCOUNT_ID`).
4788
+ * 2. `CLOUDFLARE_ACCOUNT_ID`.
4789
+ * 3. The single account `CLOUDFLARE_API_TOKEN` is scoped to, via
4790
+ * `GET /user/tokens/verify`. Multi-account tokens are rejected.
4791
+ *
4792
+ * The resolver is feature-agnostic; only the `overrideKey` differs per
4793
+ * caller. Throws on any failure with a message that names the env vars
4794
+ * the caller can set to fix it.
4795
+ */
4796
+ const TOKEN_VERIFY_URL = "https://api.cloudflare.com/client/v4/user/tokens/verify";
4797
+ const ACCOUNTS_LIST_URL = "https://api.cloudflare.com/client/v4/accounts";
4798
+ /**
4799
+ * Per-request timeout for the credential introspection calls below.
4800
+ * Without one a hung Cloudflare control-plane call wedges every
4801
+ * first-time named-tunnel `get()` on the DO (the resolver promises are
4802
+ * memoised on `Sandbox`, so the first caller's hang is everyone's hang).
4803
+ */
4804
+ const CREDENTIALS_TIMEOUT_MS = 1e4;
4805
+ /**
4806
+ * Fetch wrapper that adds an `AbortSignal.timeout` and surfaces a
4807
+ * timeout as a labelled `Error` so the caller can blame the right URL.
4808
+ */
4809
+ async function fetchWithTimeout(fetcher, url, init, timeoutMs = CREDENTIALS_TIMEOUT_MS) {
4810
+ try {
4811
+ return await fetcher(url, {
4812
+ ...init,
4813
+ signal: AbortSignal.timeout(timeoutMs)
4814
+ });
4815
+ } catch (err) {
4816
+ if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
4817
+ throw err;
4818
+ }
4819
+ }
4820
+ /**
4821
+ * Cloudflare error code returned by `GET /user/tokens/verify` when the
4822
+ * presented token is an account-owned (`cfat-`) token rather than a
4823
+ * user-owned one. Matches the heuristic wrangler uses in
4824
+ * `src/user/whoami.ts` (`getTokenType`).
4825
+ */
4826
+ const ACCOUNT_OWNED_TOKEN_CODE = 1e3;
4827
+ async function resolveAccountId(env$1, options) {
4828
+ const override = getEnvString(env$1, options.overrideKey);
4829
+ if (override) return override;
4830
+ const generic = getEnvString(env$1, "CLOUDFLARE_ACCOUNT_ID");
4831
+ if (generic) return generic;
4832
+ const token = getEnvString(env$1, "CLOUDFLARE_API_TOKEN");
4833
+ if (!token) throw new Error(`Cloudflare account id could not be resolved. Set one of: ${options.overrideKey}, CLOUDFLARE_ACCOUNT_ID, or CLOUDFLARE_API_TOKEN (a token scoped to a single account).`);
4834
+ const fetcher = options.fetcher ?? fetch;
4835
+ const response = await fetchWithTimeout(fetcher, TOKEN_VERIFY_URL, {
4836
+ method: "GET",
4837
+ headers: {
4838
+ authorization: `Bearer ${token}`,
4839
+ "content-type": "application/json"
4840
+ }
4841
+ });
4842
+ let body;
4843
+ try {
4844
+ body = await response.json();
4845
+ } catch (err) {
4846
+ const message = err instanceof Error ? err.message : String(err);
4847
+ throw new Error(`Cloudflare token verification returned malformed JSON: ${message}`);
4848
+ }
4849
+ if (response.ok && body?.success) {
4850
+ const derived = body.result_info?.account?.id;
4851
+ if (!derived) throw new Error(`Cloudflare token is not scoped to a single account (ambiguous). Set ${options.overrideKey} or CLOUDFLARE_ACCOUNT_ID explicitly.`);
4852
+ return derived;
4853
+ }
4854
+ if (body?.errors?.some((e) => e.code === ACCOUNT_OWNED_TOKEN_CODE)) return await deriveAccountIdViaAccountToken(token, fetcher, options);
4855
+ throw new Error(`Cloudflare token verification failed with status ${response.status}. Check that CLOUDFLARE_API_TOKEN is valid or set ${options.overrideKey} / CLOUDFLARE_ACCOUNT_ID explicitly.`);
4856
+ }
4857
+ /**
4858
+ * Account-owned token (cfat-) fallback: list the accounts the token can
4859
+ * see, and — if there's exactly one — confirm with the account-scoped
4860
+ * verify endpoint before returning the id.
4861
+ *
4862
+ * Common failure modes get specific, actionable error messages:
4863
+ * - `/accounts` 403 (token lacks `account:read`): tell the caller to
4864
+ * set `CLOUDFLARE_ACCOUNT_ID` explicitly.
4865
+ * - multiple accounts: same.
4866
+ * - zero accounts: same.
4867
+ * - confirm step fails: surface the API error code verbatim.
4868
+ */
4869
+ async function deriveAccountIdViaAccountToken(token, fetcher, options) {
4870
+ const listResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}?per_page=2`, {
4871
+ method: "GET",
4872
+ headers: {
4873
+ authorization: `Bearer ${token}`,
4874
+ "content-type": "application/json"
4875
+ }
4876
+ });
4877
+ let listBody;
4878
+ try {
4879
+ listBody = await listResponse.json();
4880
+ } catch (err) {
4881
+ const message = err instanceof Error ? err.message : String(err);
4882
+ throw new Error(`Cloudflare account-owned token: /accounts returned malformed JSON: ${message}`);
4883
+ }
4884
+ if (!listResponse.ok || !listBody?.success) throw new Error(`Cloudflare account-owned token (cfat-...) detected, but /accounts returned status ${listResponse.status}. The token may lack account:read scope. Set CLOUDFLARE_ACCOUNT_ID explicitly to skip introspection.`);
4885
+ const accounts = listBody.result ?? [];
4886
+ if (accounts.length === 0) throw new Error("Cloudflare account-owned token has access to no accounts. Set CLOUDFLARE_ACCOUNT_ID explicitly.");
4887
+ if (accounts.length > 1) throw new Error("Cloudflare account-owned token has access to multiple accounts (ambiguous). Set CLOUDFLARE_ACCOUNT_ID explicitly to disambiguate.");
4888
+ const accountId = accounts[0]?.id;
4889
+ if (!accountId) throw new Error("Cloudflare /accounts returned a result without an id field.");
4890
+ const verifyResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}/${encodeURIComponent(accountId)}/tokens/verify`, {
4891
+ method: "GET",
4892
+ headers: {
4893
+ authorization: `Bearer ${token}`,
4894
+ "content-type": "application/json"
4895
+ }
4896
+ });
4897
+ let verifyBody;
4898
+ try {
4899
+ verifyBody = await verifyResponse.json();
4900
+ } catch (err) {
4901
+ const message = err instanceof Error ? err.message : String(err);
4902
+ throw new Error(`Cloudflare account token verify returned malformed JSON: ${message}`);
4903
+ }
4904
+ if (!verifyResponse.ok || !verifyBody?.success) {
4905
+ const detail = verifyBody?.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ?? `HTTP ${verifyResponse.status}`;
4906
+ throw new Error(`Cloudflare account token verify failed for account ${accountId}: ${detail}`);
4907
+ }
4908
+ return accountId;
4909
+ }
4910
+ const ZONES_LIST_URL = "https://api.cloudflare.com/client/v4/zones";
4911
+ /**
4912
+ * Resolve a Cloudflare zone id.
4913
+ *
4914
+ * Precedence:
4915
+ * 1. `CLOUDFLARE_ZONE_ID` env var.
4916
+ * 2. The single zone the token can see under `accountId`, via
4917
+ * `GET /zones?account.id=<accountId>&per_page=2`.
4918
+ *
4919
+ * Step 2 deliberately fetches at most two results: one is the happy path,
4920
+ * two (or more) means the token is ambiguous and we refuse to guess.
4921
+ * Multi-zone tokens must set `CLOUDFLARE_ZONE_ID` explicitly so the
4922
+ * caller's intent is unambiguous.
4923
+ */
4924
+ async function resolveZoneId(env$1, options) {
4925
+ const envZone = getEnvString(env$1, "CLOUDFLARE_ZONE_ID");
4926
+ if (envZone) return envZone;
4927
+ const response = await fetchWithTimeout(options.fetcher ?? fetch, `${ZONES_LIST_URL}?account.id=${encodeURIComponent(options.accountId)}&per_page=2`, {
4928
+ method: "GET",
4929
+ headers: {
4930
+ authorization: `Bearer ${options.token}`,
4931
+ "content-type": "application/json"
4932
+ }
4933
+ });
4934
+ if (!response.ok) throw new Error(`Cloudflare zones lookup failed with status ${response.status}. Set CLOUDFLARE_ZONE_ID explicitly or grant the API token Zone:Read.`);
4935
+ let body;
4936
+ try {
4937
+ body = await response.json();
4938
+ } catch (err) {
4939
+ const message = err instanceof Error ? err.message : String(err);
4940
+ throw new Error(`Cloudflare zones lookup returned malformed JSON: ${message}`);
4941
+ }
4942
+ const zones = body.result ?? [];
4943
+ if (zones.length === 0) throw new Error(`Cloudflare API token has access to no zones in account ${options.accountId}. Set CLOUDFLARE_ZONE_ID explicitly or grant the token Zone:Read on the intended zone.`);
4944
+ if (zones.length > 1) throw new Error(`Cloudflare API token has access to multiple zones in account ${options.accountId} (ambiguous). Set CLOUDFLARE_ZONE_ID explicitly to disambiguate.`);
4945
+ const zoneId = zones[0]?.id;
4946
+ if (!zoneId) throw new Error("Cloudflare zones lookup returned a result without an id field.");
4947
+ return zoneId;
4948
+ }
4949
+
4655
4950
  //#endregion
4656
4951
  //#region src/tunnels/sandbox-control-callback.ts
4657
4952
  var SandboxControlCallbackImpl = class extends RpcTarget {
@@ -4674,6 +4969,229 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
4674
4969
  }
4675
4970
  };
4676
4971
 
4972
+ //#endregion
4973
+ //#region src/tunnels/cloudflare-api.ts
4974
+ /**
4975
+ * Cloudflare API client for named-tunnel orchestration.
4976
+ *
4977
+ * Design notes:
4978
+ *
4979
+ * - The Cloudflare API envelope is `{ success, result, errors }`. We
4980
+ * unwrap `result` on success and surface a thrown `Error` with the
4981
+ * API error code/message on failure. Transport-level errors
4982
+ * propagate unchanged.
4983
+ * - Delete endpoints are idempotent from the caller's perspective:
4984
+ * a 404 (already gone) resolves successfully so destroy() can run
4985
+ * without special-casing.
4986
+ * - `upsertCNAME` is the most subtle wrapper: it lists existing
4987
+ * records, reuses a matching one, and refuses to mutate a record
4988
+ * whose content differs from what we want. This is the fence that
4989
+ * stops two sandboxes from racing on the same hostname.
4990
+ */
4991
+ const API_BASE = "https://api.cloudflare.com/client/v4";
4992
+ /**
4993
+ * Default request timeout. Cloudflare API P99 latency is well under
4994
+ * this; values much smaller risk false positives on cold control-plane
4995
+ * paths (e.g. first `cfd_tunnel` POST in a new account).
4996
+ */
4997
+ const DEFAULT_TIMEOUT_MS = 1e4;
4998
+ /**
4999
+ * Internal request helper. Centralises auth header, JSON encoding,
5000
+ * timeout enforcement, and envelope unwrapping so each wrapper above
5001
+ * stays declarative.
5002
+ */
5003
+ async function cfRequest(url, token, fetcher, options = {}) {
5004
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5005
+ const init = {
5006
+ method: options.method ?? "GET",
5007
+ headers: {
5008
+ authorization: `Bearer ${token}`,
5009
+ "content-type": "application/json"
5010
+ },
5011
+ signal: AbortSignal.timeout(timeoutMs)
5012
+ };
5013
+ if (options.body !== void 0) init.body = JSON.stringify(options.body);
5014
+ let response;
5015
+ try {
5016
+ response = await fetcher(url, init);
5017
+ } catch (err) {
5018
+ if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
5019
+ throw err;
5020
+ }
5021
+ if (options.acceptStatuses?.includes(response.status)) return;
5022
+ let envelope;
5023
+ try {
5024
+ envelope = await response.json();
5025
+ } catch (err) {
5026
+ const message = err instanceof Error ? err.message : String(err);
5027
+ throw new Error(`Cloudflare API returned non-JSON response (status ${response.status}): ${message}`);
5028
+ }
5029
+ if (!response.ok || envelope.success === false) {
5030
+ const errs = envelope.errors ?? [];
5031
+ const summary = errs.length ? errs.map((e) => `${e.code ?? "???"}: ${e.message ?? "unknown"}`).join(", ") : `HTTP ${response.status}`;
5032
+ throw new Error(`Cloudflare API error: ${summary}`);
5033
+ }
5034
+ return envelope.result;
5035
+ }
5036
+ /**
5037
+ * Heuristic for the "tags are an Enterprise-only feature" error class.
5038
+ * Empirically grounded against a non-Enterprise account:
5039
+ *
5040
+ * - DNS create with `tags: [...]` on a non-Enterprise zone rejects with
5041
+ * Cloudflare error code 9300 and the message "DNS record has N tags,
5042
+ * exceeding the quota of 0.". The error string `cfRequest` constructs
5043
+ * embeds both the code and the message, so we match on either signal.
5044
+ * - Tunnel create with `tags: [...]` silently succeeds and drops the
5045
+ * field on the floor (no error to retry on). The fallback wrapper
5046
+ * therefore costs nothing on tunnel writes.
5047
+ *
5048
+ * Generic "requires Enterprise" phrasing is also matched as a forward-
5049
+ * compatibility hedge in case Cloudflare changes the response shape on
5050
+ * future endpoints.
5051
+ */
5052
+ function isEnterpriseOnlyTagError(error) {
5053
+ if (!(error instanceof Error)) return false;
5054
+ const msg = error.message.toLowerCase();
5055
+ if (msg.includes("9300") && msg.includes("tag")) return true;
5056
+ if (!msg.includes("tag")) return false;
5057
+ return msg.includes("quota") || msg.includes("enterprise") || msg.includes("not allowed") || msg.includes("not entitled") || msg.includes("not available") || msg.includes("not supported");
5058
+ }
5059
+ /**
5060
+ * Build the `tags` field attached to created Cloudflare resources. The
5061
+ * tag is `sandboxId:<id>`, the same key used in DNS comments / tunnel
5062
+ * metadata; together they let an operator find every resource a given
5063
+ * sandbox owns from the Cloudflare dashboard.
5064
+ *
5065
+ * Tags are an Enterprise-only feature. The wrapper `createWithTagFallback`
5066
+ * automatically retries the request without tags on the documented
5067
+ * "requires Enterprise" error so non-enterprise accounts succeed without
5068
+ * any configuration.
5069
+ */
5070
+ function buildSandboxTags(sandboxId) {
5071
+ if (!sandboxId) return void 0;
5072
+ return [`sandboxId:${sandboxId}`];
5073
+ }
5074
+ /**
5075
+ * Wrap a tagged-create request with an automatic tag-strip retry. The
5076
+ * callback receives `tags`: pass it through to the request body as-is on
5077
+ * the first call (`undefined` on the retry). The retry only fires for
5078
+ * the Enterprise-only tag error class; any other failure surfaces
5079
+ * verbatim.
5080
+ */
5081
+ async function createWithTagFallback(sandboxId, send) {
5082
+ const tags = buildSandboxTags(sandboxId);
5083
+ if (!tags) return send(void 0);
5084
+ try {
5085
+ return await send(tags);
5086
+ } catch (err) {
5087
+ if (!isEnterpriseOnlyTagError(err)) throw err;
5088
+ return send(void 0);
5089
+ }
5090
+ }
5091
+ async function createTunnel(args) {
5092
+ const fetcher = args.fetcher ?? fetch;
5093
+ const result = await createWithTagFallback(args.metadata.sandboxId, (tags) => cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel`, args.token, fetcher, {
5094
+ method: "POST",
5095
+ body: {
5096
+ name: args.tunnelName,
5097
+ config_src: "cloudflare",
5098
+ metadata: args.metadata,
5099
+ ...tags ? { tags } : {}
5100
+ }
5101
+ }));
5102
+ if (!result) throw new Error("Cloudflare tunnel create returned no result body");
5103
+ return {
5104
+ id: result.id,
5105
+ token: result.token
5106
+ };
5107
+ }
5108
+ /**
5109
+ * Look up an existing tunnel by exact name match. Filters out tunnels
5110
+ * marked `deleted_at != null` defensively in case the API ignores the
5111
+ * `is_deleted=false` query parameter.
5112
+ *
5113
+ * When `expectedSandboxId` is provided, also verify that the tunnel's
5114
+ * `metadata.sandboxId` tag matches — this is the authoritative "this
5115
+ * resource was created by this sandbox" check, and the tag is set by
5116
+ * `createTunnel`. Mismatches are treated as "not found" so the caller
5117
+ * falls through to creating a fresh tunnel.
5118
+ */
5119
+ async function findTunnelByName(args) {
5120
+ const fetcher = args.fetcher ?? fetch;
5121
+ const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel?name=${encodeURIComponent(args.tunnelName)}&is_deleted=false`, args.token, fetcher);
5122
+ if (!result) return null;
5123
+ const live = result.find((t) => !t.deleted_at);
5124
+ if (!live) return null;
5125
+ if (args.expectedSandboxId !== void 0) {
5126
+ if (live.metadata?.sandboxId !== args.expectedSandboxId) return null;
5127
+ }
5128
+ return {
5129
+ id: live.id,
5130
+ name: live.name
5131
+ };
5132
+ }
5133
+ async function deleteTunnel(args) {
5134
+ const fetcher = args.fetcher ?? fetch;
5135
+ await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}`, args.token, fetcher, {
5136
+ method: "DELETE",
5137
+ acceptStatuses: [404]
5138
+ });
5139
+ }
5140
+ /**
5141
+ * Fetch the opaque `--token` for an existing tunnel. Used on the retry
5142
+ * path: when `findTunnelByName` discovers a tunnel left behind from a
5143
+ * previous failed attempt, we need its token to run `cloudflared` again.
5144
+ *
5145
+ * The Cloudflare API returns the token as a bare quoted string in the
5146
+ * `result` envelope (e.g. `"<base64-token>"`).
5147
+ */
5148
+ async function getTunnelToken(args) {
5149
+ const fetcher = args.fetcher ?? fetch;
5150
+ const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}/token`, args.token, fetcher);
5151
+ if (typeof result !== "string" || result.length === 0) throw new Error(`Cloudflare did not return a token for tunnel ${args.tunnelId}`);
5152
+ return result;
5153
+ }
5154
+ async function getZoneName(args) {
5155
+ const fetcher = args.fetcher ?? fetch;
5156
+ const result = await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}`, args.token, fetcher);
5157
+ if (!result?.name) throw new Error(`Cloudflare zone ${args.zoneId} did not return a name`);
5158
+ return result.name;
5159
+ }
5160
+ async function upsertCNAME(args) {
5161
+ const fetcher = args.fetcher ?? fetch;
5162
+ const existing = (await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records?type=CNAME&name=${encodeURIComponent(args.hostname)}`, args.token, fetcher) ?? []).find((r) => r.type === "CNAME" && r.name === args.hostname);
5163
+ if (existing) {
5164
+ if (existing.content === args.cnameTarget) return {
5165
+ recordId: existing.id,
5166
+ reused: true
5167
+ };
5168
+ throw new Error(`DNS record for ${args.hostname} already exists with different content (owned by you, not us): existing content="${existing.content}", existing comment="${existing.comment ?? ""}". Delete the record manually to allow the sandbox to manage it.`);
5169
+ }
5170
+ const createResult = await createWithTagFallback(args.sandboxId, (tags) => cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records`, args.token, fetcher, {
5171
+ method: "POST",
5172
+ body: {
5173
+ type: "CNAME",
5174
+ name: args.hostname,
5175
+ content: args.cnameTarget,
5176
+ proxied: true,
5177
+ comment: args.comment,
5178
+ ...tags ? { tags } : {}
5179
+ }
5180
+ }));
5181
+ if (!createResult) throw new Error("Cloudflare DNS create returned no result body");
5182
+ return {
5183
+ recordId: createResult.id,
5184
+ reused: false
5185
+ };
5186
+ }
5187
+ async function deleteDNSRecord(args) {
5188
+ const fetcher = args.fetcher ?? fetch;
5189
+ await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records/${encodeURIComponent(args.recordId)}`, args.token, fetcher, {
5190
+ method: "DELETE",
5191
+ acceptStatuses: [404]
5192
+ });
5193
+ }
5194
+
4677
5195
  //#endregion
4678
5196
  //#region src/tunnels/tunnels-handler.ts
4679
5197
  /**
@@ -4688,6 +5206,14 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
4688
5206
  */
4689
5207
  /** DO storage key for the `port → TunnelInfo` map. */
4690
5208
  const STORAGE_KEY = "tunnels";
5209
+ /**
5210
+ * Sidecar storage key for per-port metadata the handler needs but the
5211
+ * public `TunnelInfo` shape does not carry: the options hash used to
5212
+ * detect divergent retries, and (for named tunnels) the DNS record id
5213
+ * needed for cleanup. Kept under a separate key so the existing
5214
+ * `tunnels` shape remains a clean `Record<port, TunnelInfo>`.
5215
+ */
5216
+ const META_STORAGE_KEY = "tunnels:meta";
4691
5217
  function validateTunnelPort(port) {
4692
5218
  if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
4693
5219
  }
@@ -4697,47 +5223,142 @@ function shortId() {
4697
5223
  crypto.getRandomValues(buf);
4698
5224
  return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
4699
5225
  }
5226
+ /**
5227
+ * Match a structured SandboxError code anywhere on the error — translated
5228
+ * SandboxErrors expose the code both as a top-level `code` field and on
5229
+ * the nested `errorResponse.code`. Used for the few error codes the SDK
5230
+ * recognises and recovers from (TUNNEL_NOT_FOUND, TUNNEL_ALREADY_RUNNING).
5231
+ *
5232
+ * Previous versions matched by substring on `error.message`, which
5233
+ * false-positived on any error whose message merely quoted the literal
5234
+ * code token.
5235
+ */
5236
+ function hasErrorCode(error, code) {
5237
+ if (!error || typeof error !== "object") return false;
5238
+ const e = error;
5239
+ if (e.code === code) return true;
5240
+ if (e.errorResponse?.code === code) return true;
5241
+ return false;
5242
+ }
4700
5243
  function isTunnelNotFoundError(error) {
4701
- return (error instanceof Error ? error.message : String(error)).includes("TUNNEL_NOT_FOUND");
5244
+ return hasErrorCode(error, "TUNNEL_NOT_FOUND");
5245
+ }
5246
+ function isTunnelAlreadyRunningError(error) {
5247
+ return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
4702
5248
  }
4703
5249
  async function readMap(storage) {
4704
5250
  return await storage.get(STORAGE_KEY) ?? {};
4705
5251
  }
5252
+ async function readMetaMap(storage) {
5253
+ return await storage.get(META_STORAGE_KEY) ?? {};
5254
+ }
4706
5255
  /**
4707
- * Concrete `TunnelsHandler` implementation. Extends `RpcTarget` so it
4708
- * can cross the Workers RPC boundary: the Sandbox DO is reachable from
4709
- * Workers via Workers RPC (`stub.tunnels.get(port)`), and only
4710
- * `RpcTarget` instances are passed by reference across that boundary.
5256
+ * Stable hash of `options`. Empty/undefined options collapse to the same
5257
+ * hash so `get(port)`, `get(port, {})`, and `get(port, { name: undefined })`
5258
+ * all hit the same cache entry. Named tunnels hash on `name` alone (the
5259
+ * only option today).
5260
+ *
5261
+ * The `v1:` prefix exists so a future addition of a second option (e.g.
5262
+ * `subdomain`) can change the canonical form without colliding with an
5263
+ * older record's hash. Comparison goes through `optionsHashesEqual`, which
5264
+ * normalises legacy unversioned hashes (`quick`, `named:foo`) to their v1
5265
+ * form before equality, so upgrading does not invalidate cached records.
5266
+ */
5267
+ function computeOptionsHash(options) {
5268
+ if (!options || !options.name) return "v1:quick";
5269
+ return `v1:named:${options.name}`;
5270
+ }
5271
+ /** Strip the optional `v1:` prefix so legacy hashes compare equal. */
5272
+ function normaliseHash(hash) {
5273
+ return hash.startsWith("v1:") ? hash.slice(3) : hash;
5274
+ }
5275
+ function optionsHashesEqual(a, b) {
5276
+ return normaliseHash(a) === normaliseHash(b);
5277
+ }
5278
+ /**
5279
+ * Concrete `TunnelsHandler` implementation.
5280
+ *
5281
+ * Extends `RpcTarget` for forward compatibility with direct Workers RPC
5282
+ * pipelining (`stub.tunnels.get(port)`): only `RpcTarget` instances may
5283
+ * be passed by reference across the Workers RPC boundary. Today the
5284
+ * public `sandbox.tunnels` proxy in `getSandbox()` dispatches through
5285
+ * `stub.callTunnels(method, args)` instead — pipelining through
5286
+ * property getters is broken under the vite-plugin runtime — so the
5287
+ * `RpcTarget` base is not on the hot call path. It is retained so the
5288
+ * pipelining shape works once that constraint lifts.
4711
5289
  */
4712
5290
  var TunnelsRpcTarget = class extends RpcTarget$1 {
4713
5291
  #host;
4714
5292
  #withPortLock;
5293
+ /**
5294
+ * Memoised zone name (e.g. `'example.com'`) for the configured
5295
+ * `CLOUDFLARE_ZONE_ID`. Filled in lazily on the first named-tunnel
5296
+ * `get()` so quick-tunnel callers never hit the zone-lookup endpoint.
5297
+ *
5298
+ * Only successful resolutions are cached: a rejected lookup clears
5299
+ * the slot so the next caller retries, instead of permanently
5300
+ * poisoning every subsequent named-tunnel `get()` on the DO with the
5301
+ * same transient error.
5302
+ */
5303
+ #zoneNamePromise = null;
4715
5304
  constructor(host, withPortLock) {
4716
5305
  super();
4717
5306
  this.#host = host;
4718
5307
  this.#withPortLock = withPortLock;
4719
5308
  }
4720
- async get(port) {
5309
+ /**
5310
+ * Resolve the zone name for the configured zone id. Memoised for the
5311
+ * lifetime of this handler; the zone name doesn't change while a DO
5312
+ * is alive, and one extra GET on first use is cheaper than threading
5313
+ * the value through the host.
5314
+ *
5315
+ * On failure the cached promise is cleared so the next caller retries.
5316
+ * Without that, a transient 5xx on the first call would permanently
5317
+ * poison every subsequent named-tunnel `get()` until the DO restarts.
5318
+ */
5319
+ async #getZoneName(config) {
5320
+ if (!this.#zoneNamePromise) {
5321
+ const pending = getZoneName({
5322
+ token: config.token,
5323
+ zoneId: config.zoneId,
5324
+ fetcher: this.#host.fetcher
5325
+ });
5326
+ this.#zoneNamePromise = pending;
5327
+ pending.catch(() => {
5328
+ if (this.#zoneNamePromise === pending) this.#zoneNamePromise = null;
5329
+ });
5330
+ }
5331
+ return this.#zoneNamePromise;
5332
+ }
5333
+ async get(port, options) {
4721
5334
  const startTime = Date.now();
4722
5335
  let outcome = "error";
4723
5336
  let cacheState = "miss";
4724
5337
  let caughtError;
4725
5338
  try {
4726
5339
  validateTunnelPort(port);
5340
+ if (options?.name !== void 0) validateTunnelName(options.name);
5341
+ const requestedHash = computeOptionsHash(options);
4727
5342
  const info = await this.#withPortLock(port, async () => {
4728
5343
  const existing = (await readMap(this.#host.storage))[port.toString()];
4729
5344
  if (existing) {
5345
+ const metaEntry = (await readMetaMap(this.#host.storage))[port.toString()];
5346
+ if (!optionsHashesEqual(metaEntry?.optionsHash ?? (existing.name ? `v1:named:${existing.name}` : "v1:quick"), requestedHash)) throw new Error(`Tunnel on port ${port} was created with different options. Call destroy(${port}) before changing tunnel options.`);
5347
+ if (metaEntry?.needsRespawn && existing.name) return await this.#provisionNamedTunnel(port, existing.name);
5348
+ if (existing.name && this.#host.getNamedTunnelConfig) {
5349
+ const currentConfig = await this.#host.getNamedTunnelConfig();
5350
+ const storedAccountId = metaEntry?.accountId;
5351
+ const storedZoneId = metaEntry?.zoneId;
5352
+ if (storedAccountId !== void 0 && storedAccountId !== currentConfig.accountId || storedZoneId !== void 0 && storedZoneId !== currentConfig.zoneId) {
5353
+ this.#zoneNamePromise = null;
5354
+ return await this.#provisionNamedTunnel(port, existing.name);
5355
+ }
5356
+ }
4730
5357
  cacheState = "hit";
4731
5358
  return existing;
4732
5359
  }
4733
- const id = `quick-${shortId()}`;
4734
- const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
4735
- await this.#host.storage.transaction(async (txn) => {
4736
- const nextMap = await readMap(txn);
4737
- nextMap[port.toString()] = spawned;
4738
- await txn.put(STORAGE_KEY, nextMap);
4739
- });
4740
- return spawned;
5360
+ if (options?.name) return await this.#provisionNamedTunnel(port, options.name);
5361
+ return await this.#provisionQuickTunnel(port);
4741
5362
  });
4742
5363
  outcome = "success";
4743
5364
  return info;
@@ -4755,6 +5376,129 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
4755
5376
  });
4756
5377
  }
4757
5378
  }
5379
+ /**
5380
+ * Provision a fresh quick tunnel and persist it. Caller holds the
5381
+ * per-port lock.
5382
+ *
5383
+ * Quick-tunnel ids are minted from a 32-bit random source. Collisions
5384
+ * are astronomically unlikely, but if the container happens to already
5385
+ * have one running under the freshly-minted id it rejects with
5386
+ * TUNNEL_ALREADY_RUNNING. Mint a fresh id and try again rather than
5387
+ * surfacing the confusing error — the retry budget caps the loop so a
5388
+ * persistent failure still surfaces.
5389
+ */
5390
+ async #provisionQuickTunnel(port) {
5391
+ const MAX_ID_RETRIES = 3;
5392
+ let lastError;
5393
+ for (let attempt = 0; attempt < MAX_ID_RETRIES; attempt += 1) {
5394
+ const id = `quick-${shortId()}`;
5395
+ try {
5396
+ const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
5397
+ await this.#host.storage.transaction(async (txn) => {
5398
+ const nextMap = await readMap(txn);
5399
+ nextMap[port.toString()] = spawned;
5400
+ await txn.put(STORAGE_KEY, nextMap);
5401
+ const nextMeta = await readMetaMap(txn);
5402
+ nextMeta[port.toString()] = { optionsHash: "v1:quick" };
5403
+ await txn.put(META_STORAGE_KEY, nextMeta);
5404
+ });
5405
+ return spawned;
5406
+ } catch (err) {
5407
+ if (!isTunnelAlreadyRunningError(err)) throw err;
5408
+ lastError = err;
5409
+ }
5410
+ }
5411
+ throw lastError ?? /* @__PURE__ */ new Error("Failed to mint a unique quick-tunnel id");
5412
+ }
5413
+ /**
5414
+ * Provision a named tunnel end-to-end:
5415
+ * 1. resolve credentials + zone name
5416
+ * 2. reuse or create the Cloudflare tunnel resource
5417
+ * 3. upsert the proxied CNAME (or reuse a matching one)
5418
+ * 4. spawn cloudflared inside the container
5419
+ * 5. persist the record + meta
5420
+ *
5421
+ * Failure between (2) and (5) intentionally leaves the Cloudflare-side
5422
+ * resources in place so a retry can re-discover them via
5423
+ * `findTunnelByName` and the DNS reuse path. See
5424
+ * `.plans/09-named-tunnel-api.md § Retry-friendly failure model`.
5425
+ */
5426
+ async #provisionNamedTunnel(port, name) {
5427
+ if (!this.#host.sandboxId) throw new Error("Named tunnels require host.sandboxId on the tunnels handler.");
5428
+ if (!this.#host.getNamedTunnelConfig) throw new Error("Named tunnels require host.getNamedTunnelConfig on the tunnels handler.");
5429
+ const config = await this.#host.getNamedTunnelConfig();
5430
+ const hostname = `${name}.${await this.#getZoneName({
5431
+ token: config.token,
5432
+ zoneId: config.zoneId
5433
+ })}`;
5434
+ const sandboxId = this.#host.sandboxId;
5435
+ const tunnelName = `sandbox-${sandboxId}-${name}`;
5436
+ let tunnelId;
5437
+ let tunnelToken;
5438
+ const existingTunnel = await findTunnelByName({
5439
+ token: config.token,
5440
+ accountId: config.accountId,
5441
+ tunnelName,
5442
+ expectedSandboxId: sandboxId,
5443
+ fetcher: this.#host.fetcher
5444
+ });
5445
+ if (existingTunnel) {
5446
+ tunnelId = existingTunnel.id;
5447
+ tunnelToken = await getTunnelToken({
5448
+ token: config.token,
5449
+ accountId: config.accountId,
5450
+ tunnelId,
5451
+ fetcher: this.#host.fetcher
5452
+ });
5453
+ } else {
5454
+ const created = await createTunnel({
5455
+ token: config.token,
5456
+ accountId: config.accountId,
5457
+ tunnelName,
5458
+ metadata: {
5459
+ sandboxId,
5460
+ createdBy: "sandbox-sdk",
5461
+ name,
5462
+ port
5463
+ },
5464
+ fetcher: this.#host.fetcher
5465
+ });
5466
+ tunnelId = created.id;
5467
+ tunnelToken = created.token;
5468
+ }
5469
+ const dnsResult = await upsertCNAME({
5470
+ token: config.token,
5471
+ zoneId: config.zoneId,
5472
+ hostname,
5473
+ cnameTarget: `${tunnelId}.cfargotunnel.com`,
5474
+ comment: `sandbox-${sandboxId}`,
5475
+ sandboxId,
5476
+ fetcher: this.#host.fetcher
5477
+ });
5478
+ await this.#host.client.tunnels.runNamedTunnel(tunnelId, tunnelToken, port);
5479
+ const info = {
5480
+ id: tunnelId,
5481
+ port,
5482
+ name,
5483
+ hostname,
5484
+ url: `https://${hostname}`,
5485
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5486
+ };
5487
+ await this.#host.storage.transaction(async (txn) => {
5488
+ const nextMap = await readMap(txn);
5489
+ nextMap[port.toString()] = info;
5490
+ await txn.put(STORAGE_KEY, nextMap);
5491
+ const nextMeta = await readMetaMap(txn);
5492
+ nextMeta[port.toString()] = {
5493
+ optionsHash: computeOptionsHash({ name }),
5494
+ dnsRecordId: dnsResult.recordId,
5495
+ accountId: config.accountId,
5496
+ zoneId: config.zoneId
5497
+ };
5498
+ await txn.put(META_STORAGE_KEY, nextMeta);
5499
+ });
5500
+ return info;
5501
+ }
4758
5502
  async destroy(portOrInfo) {
4759
5503
  const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
4760
5504
  const startTime = Date.now();
@@ -4766,16 +5510,68 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
4766
5510
  const existing = (await readMap(this.#host.storage))[port.toString()];
4767
5511
  if (!existing) return;
4768
5512
  tunnelId = existing.id;
5513
+ const metaBefore = (await readMetaMap(this.#host.storage))[port.toString()];
4769
5514
  await this.#host.storage.transaction(async (txn) => {
4770
5515
  const current = await readMap(txn);
4771
5516
  delete current[port.toString()];
4772
5517
  await txn.put(STORAGE_KEY, current);
5518
+ const currentMeta = await readMetaMap(txn);
5519
+ delete currentMeta[port.toString()];
5520
+ await txn.put(META_STORAGE_KEY, currentMeta);
4773
5521
  });
4774
5522
  try {
4775
5523
  await this.#host.client.tunnels.destroyTunnel(existing.id);
4776
5524
  } catch (error) {
4777
- if (!isTunnelNotFoundError(error)) throw error;
5525
+ if (isTunnelNotFoundError(error)) {} else if (metaBefore?.dnsRecordId) this.#host.logger.warn("tunnel.destroy: container tunnel cleanup failed", {
5526
+ port,
5527
+ tunnelId,
5528
+ error: error instanceof Error ? error.message : String(error)
5529
+ });
5530
+ else throw error;
4778
5531
  }
5532
+ if (!metaBefore?.dnsRecordId) return;
5533
+ if (!this.#host.getNamedTunnelConfig) return;
5534
+ let config;
5535
+ try {
5536
+ config = await this.#host.getNamedTunnelConfig();
5537
+ } catch (err) {
5538
+ this.#host.logger.warn("tunnel.destroy: skipping CF cleanup, credentials unavailable", {
5539
+ port,
5540
+ tunnelId,
5541
+ dnsRecordId: metaBefore.dnsRecordId,
5542
+ error: err instanceof Error ? err.message : String(err)
5543
+ });
5544
+ return;
5545
+ }
5546
+ const fetcher = this.#host.fetcher;
5547
+ const accountId = metaBefore.accountId ?? config.accountId;
5548
+ const zoneId = metaBefore.zoneId ?? config.zoneId;
5549
+ await Promise.allSettled([metaBefore.dnsRecordId ? deleteDNSRecord({
5550
+ token: config.token,
5551
+ zoneId,
5552
+ recordId: metaBefore.dnsRecordId,
5553
+ fetcher
5554
+ }).catch((err) => {
5555
+ this.#host.logger.warn("tunnel.destroy: dns delete failed", {
5556
+ port,
5557
+ tunnelId,
5558
+ recordId: metaBefore.dnsRecordId,
5559
+ zoneId,
5560
+ error: err instanceof Error ? err.message : String(err)
5561
+ });
5562
+ }) : Promise.resolve(), deleteTunnel({
5563
+ token: config.token,
5564
+ accountId,
5565
+ tunnelId: existing.id,
5566
+ fetcher
5567
+ }).catch((err) => {
5568
+ this.#host.logger.warn("tunnel.destroy: tunnel delete failed", {
5569
+ port,
5570
+ tunnelId,
5571
+ accountId,
5572
+ error: err instanceof Error ? err.message : String(err)
5573
+ });
5574
+ })]);
4779
5575
  });
4780
5576
  outcome = "success";
4781
5577
  } catch (error) {
@@ -4807,29 +5603,126 @@ function createTunnelsHandler(host) {
4807
5603
  const tunnels = new TunnelsRpcTarget(host, withPortLock);
4808
5604
  const handleTunnelExit = async (id, port, exitCode) => {
4809
5605
  const startTime = Date.now();
4810
- await withPortLock(port, async () => {
4811
- await host.storage.transaction(async (txn) => {
4812
- const map = await readMap(txn);
4813
- if (map[port.toString()]?.id === id) {
5606
+ let outcome = "error";
5607
+ let caughtError;
5608
+ try {
5609
+ await withPortLock(port, async () => {
5610
+ await host.storage.transaction(async (txn) => {
5611
+ const map = await readMap(txn);
5612
+ const existing = map[port.toString()];
5613
+ if (existing?.id !== id) return;
5614
+ if (existing.name) {
5615
+ const meta$1 = await readMetaMap(txn);
5616
+ meta$1[port.toString()] = {
5617
+ ...meta$1[port.toString()],
5618
+ optionsHash: meta$1[port.toString()]?.optionsHash ?? `v1:named:${existing.name}`,
5619
+ needsRespawn: true
5620
+ };
5621
+ await txn.put(META_STORAGE_KEY, meta$1);
5622
+ return;
5623
+ }
4814
5624
  delete map[port.toString()];
4815
5625
  await txn.put(STORAGE_KEY, map);
4816
- }
5626
+ const meta = await readMetaMap(txn);
5627
+ delete meta[port.toString()];
5628
+ await txn.put(META_STORAGE_KEY, meta);
5629
+ });
4817
5630
  });
5631
+ outcome = "success";
5632
+ } catch (error) {
5633
+ caughtError = error instanceof Error ? error : new Error(String(error));
5634
+ throw error;
5635
+ } finally {
4818
5636
  logCanonicalEvent(host.logger, {
4819
5637
  event: "tunnel.exit",
4820
- outcome: "success",
5638
+ outcome,
4821
5639
  port,
4822
5640
  tunnelId: id,
4823
5641
  exitCode: exitCode ?? void 0,
4824
- durationMs: Date.now() - startTime
5642
+ durationMs: Date.now() - startTime,
5643
+ error: caughtError
4825
5644
  });
4826
- });
5645
+ }
5646
+ };
5647
+ /**
5648
+ * Iterate every stored tunnel and call `tunnels.destroy(port)` on it,
5649
+ * sequentially. Each `destroy()` already swallows container-side
5650
+ * TUNNEL_NOT_FOUND and best-effort-logs Cloudflare-side failures; we
5651
+ * wrap the call in catch-and-log here too so a transport-level error
5652
+ * on one port can't poison the rest of the teardown.
5653
+ *
5654
+ * Each port is processed sequentially: this caps the *number of
5655
+ * concurrent ports* in flight at one. Note that an individual
5656
+ * destroy() still fans the DNS-delete and tunnel-delete out via
5657
+ * `Promise.allSettled` internally — so "sequential" here means
5658
+ * "one port at a time", not "one Cloudflare API call at a time".
5659
+ * The handful of ports we expect in the common case makes the
5660
+ * trade-off cheap.
5661
+ */
5662
+ const destroyAll = async () => {
5663
+ const map = await readMap(host.storage);
5664
+ const ports = Object.keys(map).map((p) => Number(p));
5665
+ for (const port of ports) try {
5666
+ await tunnels.destroy(port);
5667
+ } catch (err) {
5668
+ host.logger.warn("tunnels.destroyAll: destroy(port) failed", {
5669
+ port,
5670
+ error: err instanceof Error ? err.message : String(err)
5671
+ });
5672
+ }
4827
5673
  };
4828
5674
  return {
4829
5675
  tunnels,
4830
- handleTunnelExit
5676
+ handleTunnelExit,
5677
+ destroyAll
4831
5678
  };
4832
5679
  }
5680
+ /**
5681
+ * Reconcile storage with a fresh container.
5682
+ *
5683
+ * Called from `Sandbox.onStart()` after every container restart. The
5684
+ * `cloudflared` processes the container was running all died with it, so
5685
+ * any stored record is *not* currently backed by a running tunnel.
5686
+ *
5687
+ * Two tunnel flavours, two recovery stories:
5688
+ *
5689
+ * - Quick tunnels: the `*.trycloudflare.com` URL is bound to the dead
5690
+ * `cloudflared` process. Nothing on Cloudflare's side outlives the
5691
+ * container, and the URL is unrecoverable. Drop the record from both
5692
+ * maps so the next `get(port)` takes the miss branch and mints a new
5693
+ * URL.
5694
+ * - Named tunnels: the Cloudflare-side tunnel + DNS record survive.
5695
+ * The hostname is stable, the DNS still resolves to
5696
+ * `<tunnelId>.cfargotunnel.com`, and the next caller can reuse both
5697
+ * by walking the same `findTunnelByName` / `upsertCNAME` path the
5698
+ * SDK uses for retries. Keep the record in storage and mark the
5699
+ * meta entry `needsRespawn: true`; the next `get(port, { name })`
5700
+ * cache hit falls through to `#provisionNamedTunnel` to respawn
5701
+ * `cloudflared`.
5702
+ *
5703
+ * Crucially, named-tunnel metadata (including `dnsRecordId`) is
5704
+ * preserved so `destroy(port)` and `sandbox.destroy()` can still clean
5705
+ * up the Cloudflare-side resources after a restart. Wiping meta
5706
+ * unconditionally — the previous behaviour — silently leaked the tunnel
5707
+ * and DNS record on every restart.
5708
+ */
5709
+ async function pruneTunnelsForRestart(storage) {
5710
+ await storage.transaction(async (txn) => {
5711
+ const map = await readMap(txn);
5712
+ const meta = await readMetaMap(txn);
5713
+ const nextMap = {};
5714
+ const nextMeta = {};
5715
+ for (const [portKey, info] of Object.entries(map)) if (info.name) {
5716
+ nextMap[portKey] = info;
5717
+ nextMeta[portKey] = {
5718
+ ...meta[portKey] ?? { optionsHash: `v1:named:${info.name}` },
5719
+ needsRespawn: true
5720
+ };
5721
+ }
5722
+ await txn.put(STORAGE_KEY, nextMap);
5723
+ await txn.put(META_STORAGE_KEY, nextMeta);
5724
+ });
5725
+ }
4833
5726
 
4834
5727
  //#endregion
4835
5728
  //#region src/version.ts
@@ -4838,10 +5731,12 @@ function createTunnelsHandler(host) {
4838
5731
  * This file is auto-updated by .github/changeset-version.ts during releases
4839
5732
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
4840
5733
  */
4841
- const SDK_VERSION = "0.10.2";
5734
+ const SDK_VERSION = "0.11.0";
4842
5735
 
4843
5736
  //#endregion
4844
5737
  //#region src/sandbox.ts
5738
+ const PORT_TOKENS_STORAGE_KEY = "portTokens";
5739
+ const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
4845
5740
  const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
4846
5741
  var R2EgressProxyTarget = class extends Container {};
4847
5742
  Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
@@ -4987,14 +5882,63 @@ function getSandbox(ns, id, options) {
4987
5882
  });
4988
5883
  }
4989
5884
  const defaultSessionId = `sandbox-${effectiveId}`;
5885
+ const useDefaultSession = options?.enableDefaultSession !== false;
4990
5886
  const enhancedMethods = {
4991
5887
  fetch: (request) => stub.fetch(request),
5888
+ exec: (command, execOptions) => useDefaultSession ? stub.exec(command, execOptions) : stub.execWithSessionToken(command, DISABLE_SESSION_TOKEN, execOptions),
5889
+ startProcess: (command, processOptions) => useDefaultSession || processOptions?.sessionId !== void 0 ? stub.startProcess(command, processOptions) : stub.startProcess(command, {
5890
+ ...processOptions,
5891
+ sessionId: DISABLE_SESSION_TOKEN
5892
+ }),
5893
+ listProcesses: (sessionId) => useDefaultSession || sessionId !== void 0 ? stub.listProcesses(sessionId) : stub.listProcesses(DISABLE_SESSION_TOKEN),
5894
+ getProcess: (id$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.getProcess(id$1, sessionId) : stub.getProcess(id$1, DISABLE_SESSION_TOKEN),
5895
+ execStream: (command, streamOptions) => {
5896
+ if (useDefaultSession || streamOptions?.sessionId !== void 0) return stub.execStream(command, streamOptions);
5897
+ return stub.execStreamWithSessionToken(command, DISABLE_SESSION_TOKEN, streamOptions);
5898
+ },
5899
+ writeFile: (path$1, content, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.writeFile(path$1, content, fileOptions) : stub.writeFile(path$1, content, {
5900
+ ...fileOptions,
5901
+ sessionId: DISABLE_SESSION_TOKEN
5902
+ }),
5903
+ readFile: (path$1, fileOptions = {}) => {
5904
+ const options$1 = useDefaultSession || fileOptions.sessionId !== void 0 ? fileOptions : {
5905
+ ...fileOptions,
5906
+ sessionId: DISABLE_SESSION_TOKEN
5907
+ };
5908
+ if (options$1.encoding === "none") return stub.readFile(path$1, options$1);
5909
+ return stub.readFile(path$1, options$1);
5910
+ },
5911
+ readFileStream: (path$1, fileOptions = {}) => useDefaultSession || fileOptions.sessionId !== void 0 ? stub.readFileStream(path$1, fileOptions) : stub.readFileStream(path$1, { sessionId: DISABLE_SESSION_TOKEN }),
5912
+ mkdir: (path$1, mkdirOptions = {}) => useDefaultSession || mkdirOptions.sessionId !== void 0 ? stub.mkdir(path$1, mkdirOptions) : stub.mkdir(path$1, {
5913
+ ...mkdirOptions,
5914
+ sessionId: DISABLE_SESSION_TOKEN
5915
+ }),
5916
+ deleteFile: (path$1) => useDefaultSession ? stub.deleteFile(path$1) : stub.deleteFile(path$1, DISABLE_SESSION_TOKEN),
5917
+ renameFile: (oldPath, newPath) => useDefaultSession ? stub.renameFile(oldPath, newPath) : stub.renameFile(oldPath, newPath, DISABLE_SESSION_TOKEN),
5918
+ moveFile: (sourcePath, destinationPath) => useDefaultSession ? stub.moveFile(sourcePath, destinationPath) : stub.moveFile(sourcePath, destinationPath, DISABLE_SESSION_TOKEN),
5919
+ listFiles: (path$1, listOptions) => useDefaultSession || listOptions?.sessionId !== void 0 ? stub.listFiles(path$1, listOptions) : stub.listFiles(path$1, {
5920
+ ...listOptions,
5921
+ sessionId: DISABLE_SESSION_TOKEN
5922
+ }),
5923
+ exists: (path$1, sessionId) => useDefaultSession || sessionId !== void 0 ? stub.exists(path$1, sessionId) : stub.exists(path$1, DISABLE_SESSION_TOKEN),
5924
+ gitCheckout: (repoUrl, gitOptions) => useDefaultSession || gitOptions?.sessionId !== void 0 ? stub.gitCheckout(repoUrl, gitOptions) : stub.gitCheckout(repoUrl, {
5925
+ ...gitOptions,
5926
+ sessionId: DISABLE_SESSION_TOKEN
5927
+ }),
4992
5928
  createSession: async (opts) => {
4993
5929
  return enhanceSession(stub, await stub.createSession(opts));
4994
5930
  },
4995
5931
  getSession: async (sessionId) => {
4996
5932
  return enhanceSession(stub, await stub.getSession(sessionId));
4997
5933
  },
5934
+ watch: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.watch(path$1, options$1) : stub.watch(path$1, {
5935
+ ...options$1,
5936
+ sessionId: DISABLE_SESSION_TOKEN
5937
+ }),
5938
+ checkChanges: (path$1, options$1 = {}) => useDefaultSession || options$1.sessionId !== void 0 ? stub.checkChanges(path$1, options$1) : stub.checkChanges(path$1, {
5939
+ ...options$1,
5940
+ sessionId: DISABLE_SESSION_TOKEN
5941
+ }),
4998
5942
  terminal: (request, opts) => proxyTerminal(stub, defaultSessionId, request, opts),
4999
5943
  wsConnect: connect(stub),
5000
5944
  desktop: new Proxy({}, { get(_, method) {
@@ -5032,6 +5976,7 @@ var Sandbox = class Sandbox extends Container {
5032
5976
  sandboxName = null;
5033
5977
  tunnelsHandler = null;
5034
5978
  tunnelExitHandler = null;
5979
+ destroyAllTunnels = null;
5035
5980
  controlCallback;
5036
5981
  normalizeId = false;
5037
5982
  defaultSession = null;
@@ -5041,6 +5986,7 @@ var Sandbox = class Sandbox extends Container {
5041
5986
  logger;
5042
5987
  keepAliveEnabled = false;
5043
5988
  activeMounts = /* @__PURE__ */ new Map();
5989
+ currentRuntime;
5044
5990
  transport = "http";
5045
5991
  /**
5046
5992
  * True once transport has been written to storage at least once (either
@@ -5066,8 +6012,23 @@ var Sandbox = class Sandbox extends Container {
5066
6012
  r2SecretAccessKey = null;
5067
6013
  r2AccountId = null;
5068
6014
  backupBucketName = null;
6015
+ backupBucketEndpoint = null;
5069
6016
  r2Client = null;
5070
6017
  /**
6018
+ * Lazily-resolved Cloudflare account id for named-tunnel provisioning.
6019
+ * Resolved on first access via `tunnels/credentials.ts` and cached for
6020
+ * the lifetime of this DO instance. See the credentials helper for
6021
+ * the precedence chain.
6022
+ */
6023
+ tunnelAccountIdPromise = null;
6024
+ /**
6025
+ * Lazily-resolved Cloudflare zone id for named-tunnel provisioning.
6026
+ * Falls back to the single zone the token can see under the resolved
6027
+ * account id when `CLOUDFLARE_ZONE_ID` is not set. Cached for the
6028
+ * lifetime of this DO instance.
6029
+ */
6030
+ tunnelZoneIdPromise = null;
6031
+ /**
5071
6032
  * Default container startup timeouts (conservative for production)
5072
6033
  * Based on Cloudflare docs: "Containers take several minutes to provision"
5073
6034
  */
@@ -5226,16 +6187,64 @@ var Sandbox = class Sandbox extends Container {
5226
6187
  component: "sandbox-do",
5227
6188
  sandboxId: this.ctx.id.toString()
5228
6189
  });
6190
+ this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
5229
6191
  const transportEnv = envObj?.SANDBOX_TRANSPORT;
5230
6192
  if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
5231
6193
  else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
5232
6194
  this.logger.info(`Using ${this.transport} transport`);
5233
6195
  const backupBucket = envObj?.BACKUP_BUCKET;
5234
6196
  if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
5235
- this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
6197
+ this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
5236
6198
  this.r2AccessKeyId = getEnvString(envObj, "R2_ACCESS_KEY_ID") ?? null;
5237
6199
  this.r2SecretAccessKey = getEnvString(envObj, "R2_SECRET_ACCESS_KEY") ?? null;
5238
6200
  this.backupBucketName = getEnvString(envObj, "BACKUP_BUCKET_NAME") ?? null;
6201
+ const rawEndpoint = getEnvString(envObj, "BACKUP_BUCKET_ENDPOINT") ?? null;
6202
+ if (rawEndpoint !== null) {
6203
+ let parsed;
6204
+ try {
6205
+ parsed = new URL(rawEndpoint);
6206
+ } catch {
6207
+ const msg = `BACKUP_BUCKET_ENDPOINT is not a valid URL: "${rawEndpoint}". Expected format: https://<account_id>.eu.r2.cloudflarestorage.com`;
6208
+ throw new InvalidBackupConfigError({
6209
+ message: msg,
6210
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6211
+ httpStatus: 400,
6212
+ context: { reason: msg },
6213
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6214
+ });
6215
+ }
6216
+ if (parsed.protocol !== "https:") {
6217
+ const msg = `BACKUP_BUCKET_ENDPOINT must use https://, got "${parsed.protocol.slice(0, -1)}://"`;
6218
+ throw new InvalidBackupConfigError({
6219
+ message: msg,
6220
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6221
+ httpStatus: 400,
6222
+ context: { reason: msg },
6223
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6224
+ });
6225
+ }
6226
+ if (parsed.pathname !== "/") {
6227
+ const msg = `BACKUP_BUCKET_ENDPOINT must not include a path (got "${parsed.pathname}"). Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com`;
6228
+ throw new InvalidBackupConfigError({
6229
+ message: msg,
6230
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6231
+ httpStatus: 400,
6232
+ context: { reason: msg },
6233
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6234
+ });
6235
+ }
6236
+ if (parsed.search !== "" || parsed.hash !== "") {
6237
+ const msg = "BACKUP_BUCKET_ENDPOINT must not include query parameters or fragments. Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com";
6238
+ throw new InvalidBackupConfigError({
6239
+ message: msg,
6240
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6241
+ httpStatus: 400,
6242
+ context: { reason: msg },
6243
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6244
+ });
6245
+ }
6246
+ this.backupBucketEndpoint = parsed.origin;
6247
+ } else this.backupBucketEndpoint = null;
5239
6248
  if (this.r2AccessKeyId && this.r2SecretAccessKey) this.r2Client = new AwsClient({
5240
6249
  accessKeyId: this.r2AccessKeyId,
5241
6250
  secretAccessKey: this.r2SecretAccessKey
@@ -5270,6 +6279,7 @@ var Sandbox = class Sandbox extends Container {
5270
6279
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5271
6280
  this.tunnelsHandler = null;
5272
6281
  this.tunnelExitHandler = null;
6282
+ this.destroyAllTunnels = null;
5273
6283
  previousClient.disconnect();
5274
6284
  }
5275
6285
  if (storedTransport) this.hasStoredTransport = true;
@@ -5325,13 +6335,6 @@ var Sandbox = class Sandbox extends Container {
5325
6335
  }
5326
6336
  }
5327
6337
  }
5328
- /**
5329
- * RPC method to configure container startup timeouts. Idempotent once
5330
- * the values have been persisted: re-applying the same timeout set is a
5331
- * no-op. The transport retry budget is recomputed only when at least
5332
- * one timeout actually changes. Storage is written before the in-memory
5333
- * mirror and derived state are updated.
5334
- */
5335
6338
  async setContainerTimeouts(timeouts) {
5336
6339
  const validated = { ...this.containerTimeouts };
5337
6340
  if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
@@ -5344,11 +6347,6 @@ var Sandbox = class Sandbox extends Container {
5344
6347
  this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
5345
6348
  this.logger.debug("Container timeouts updated", this.containerTimeouts);
5346
6349
  }
5347
- /**
5348
- * RPC method to set the transport protocol. Idempotent once the value
5349
- * has been persisted: re-applying the same transport is a no-op.
5350
- * Storage is written before the in-memory state and client are updated.
5351
- */
5352
6350
  async setTransport(transport) {
5353
6351
  if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
5354
6352
  this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
@@ -5363,23 +6361,16 @@ var Sandbox = class Sandbox extends Container {
5363
6361
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5364
6362
  this.tunnelsHandler = null;
5365
6363
  this.tunnelExitHandler = null;
6364
+ this.destroyAllTunnels = null;
5366
6365
  previousClient.disconnect();
5367
6366
  this.renewActivityTimeout();
5368
6367
  this.logger.debug("Transport updated", { transport });
5369
6368
  }
5370
- /**
5371
- * Validate a timeout value is within acceptable range
5372
- * Throws error if invalid - used for user-provided values
5373
- */
5374
6369
  validateTimeout(value, name, min, max) {
5375
6370
  if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
5376
6371
  if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
5377
6372
  return value;
5378
6373
  }
5379
- /**
5380
- * Get default timeouts with env var fallbacks and validation
5381
- * Precedence: SDK defaults < Env vars < User config
5382
- */
5383
6374
  getDefaultTimeouts(env$1) {
5384
6375
  const parseAndValidate = (envVar, name, min, max) => {
5385
6376
  const defaultValue = this.DEFAULT_CONTAINER_TIMEOUTS[name];
@@ -5742,7 +6733,7 @@ var Sandbox = class Sandbox extends Container {
5742
6733
  */
5743
6734
  async createPasswordFile(passwordFilePath, bucket, credentials) {
5744
6735
  const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
5745
- await this.writeFile(passwordFilePath, content);
6736
+ await this.client.files.writeFile(passwordFilePath, content, DISABLE_SESSION_TOKEN);
5746
6737
  await this.execInternal(`chmod 0600 ${shellEscape(passwordFilePath)}`);
5747
6738
  }
5748
6739
  /**
@@ -5842,6 +6833,9 @@ var Sandbox = class Sandbox extends Container {
5842
6833
  let outcome = "error";
5843
6834
  let caughtError;
5844
6835
  try {
6836
+ await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
6837
+ await this.clearActivePreviewPorts();
6838
+ await this.currentRuntime.clear();
5845
6839
  if (this.ctx.container?.running) try {
5846
6840
  await this.client.desktop.stop();
5847
6841
  } catch {}
@@ -5866,8 +6860,14 @@ var Sandbox = class Sandbox extends Container {
5866
6860
  await this.deletePasswordFile(mountInfo.passwordFilePath);
5867
6861
  }
5868
6862
  }
5869
- await this.ctx.storage.delete("portTokens");
6863
+ try {
6864
+ this.ensureTunnelsBuilt();
6865
+ await this.destroyAllTunnels?.();
6866
+ } catch (error) {
6867
+ this.logger.warn("Failed to tear down tunnels during destroy()", { error: error instanceof Error ? error.message : String(error) });
6868
+ }
5870
6869
  await this.ctx.storage.delete("tunnels");
6870
+ await this.ctx.storage.delete("tunnels:meta");
5871
6871
  this.client.disconnect();
5872
6872
  outcome = "success";
5873
6873
  await super.destroy();
@@ -5887,75 +6887,20 @@ var Sandbox = class Sandbox extends Container {
5887
6887
  }
5888
6888
  async onStart() {
5889
6889
  this.logger.debug("Sandbox started");
6890
+ await this.currentRuntime.markStarted();
5890
6891
  this.checkVersionCompatibility().catch((error) => {
5891
6892
  this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
5892
6893
  });
5893
6894
  try {
5894
- await this.restoreExposedPorts();
5895
- } catch (error) {
5896
- this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
5897
- }
5898
- try {
5899
- await this.ctx.storage.delete("tunnels");
6895
+ await pruneTunnelsForRestart(this.ctx.storage);
5900
6896
  } catch (error) {
5901
- this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
6897
+ this.logger.error("Failed to reconcile tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
5902
6898
  }
5903
6899
  }
5904
- /**
5905
- * Re-expose ports on the container runtime using tokens persisted in DO
5906
- * storage. Called from onStart() after a container (re)start.
5907
- *
5908
- * The DO storage holds the source of truth for which ports should be
5909
- * exposed, which tokens authorize them, and the friendly name (if any)
5910
- * that the caller set when first exposing the port. If a port is already
5911
- * exposed on the container this is a no-op for that port. Individual port
5912
- * failures are logged but do not abort the overall restore — a transient
5913
- * failure for one port must not prevent the others from being restored.
5914
- */
5915
- async restoreExposedPorts() {
5916
- const savedTokens = await this.readPortTokens();
5917
- const portEntries = Object.entries(savedTokens);
5918
- if (portEntries.length === 0) return;
5919
- const startTime = Date.now();
5920
- let restored = 0;
5921
- let skipped = 0;
5922
- let failed = 0;
5923
- const sessionId = await this.ensureDefaultSession();
5924
- const exposedSet = await this.client.ports.getExposedPorts(sessionId).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
5925
- this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
5926
- return /* @__PURE__ */ new Set();
5927
- });
5928
- for (const [portStr, entry] of portEntries) {
5929
- const port = Number.parseInt(portStr, 10);
5930
- if (!Number.isFinite(port) || !validatePort(port)) {
5931
- this.logger.warn("Skipping restore of invalid port in storage", { port: portStr });
5932
- failed++;
5933
- continue;
5934
- }
5935
- if (exposedSet.has(port)) {
5936
- skipped++;
5937
- continue;
5938
- }
5939
- try {
5940
- await this.client.ports.exposePort(port, sessionId, entry.name);
5941
- restored++;
5942
- } catch (error) {
5943
- failed++;
5944
- this.logger.warn("Failed to re-expose port on container restart", {
5945
- port,
5946
- error: error instanceof Error ? error.message : String(error)
5947
- });
5948
- }
5949
- }
5950
- logCanonicalEvent(this.logger, {
5951
- event: "port.restore",
5952
- outcome: failed === 0 ? "success" : "error",
5953
- durationMs: Date.now() - startTime,
5954
- restored,
5955
- skipped,
5956
- failed,
5957
- total: portEntries.length
5958
- });
6900
+ async stop(signal) {
6901
+ await this.currentRuntime.clear();
6902
+ await this.clearActivePreviewPorts();
6903
+ await super.stop(signal);
5959
6904
  }
5960
6905
  /**
5961
6906
  * Read the `portTokens` map from DO storage, normalizing the legacy
@@ -5963,12 +6908,32 @@ var Sandbox = class Sandbox extends Container {
5963
6908
  * ({ token, name? }). The legacy format predates port-name persistence and
5964
6909
  * can appear on any DO whose storage was written before that change.
5965
6910
  */
5966
- async readPortTokens() {
5967
- const raw = await this.ctx.storage.get("portTokens") ?? {};
6911
+ async readPortTokens(storage = this.ctx.storage) {
6912
+ const raw = await storage.get(PORT_TOKENS_STORAGE_KEY) ?? {};
5968
6913
  const normalized = {};
5969
6914
  for (const [port, value] of Object.entries(raw)) normalized[port] = typeof value === "string" ? { token: value } : value;
5970
6915
  return normalized;
5971
6916
  }
6917
+ async readActivePreviewPorts(storage = this.ctx.storage) {
6918
+ return await storage.get(ACTIVE_PREVIEW_PORTS_STORAGE_KEY) ?? {};
6919
+ }
6920
+ async writeActivePreviewPorts(activations, storage = this.ctx.storage) {
6921
+ if (Object.keys(activations).length === 0) {
6922
+ await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
6923
+ return;
6924
+ }
6925
+ await storage.put(ACTIVE_PREVIEW_PORTS_STORAGE_KEY, activations);
6926
+ }
6927
+ async readPreviewState(storage = this.ctx.storage) {
6928
+ const [tokens, activations] = await Promise.all([this.readPortTokens(storage), this.readActivePreviewPorts(storage)]);
6929
+ return {
6930
+ tokens,
6931
+ activations
6932
+ };
6933
+ }
6934
+ async clearActivePreviewPorts() {
6935
+ await this.ctx.storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
6936
+ }
5972
6937
  /**
5973
6938
  * Check if the container version matches the SDK version
5974
6939
  * Logs a warning if there's a mismatch
@@ -6001,6 +6966,13 @@ var Sandbox = class Sandbox extends Container {
6001
6966
  this.containerGeneration++;
6002
6967
  this.defaultSession = null;
6003
6968
  this.defaultSessionInit = null;
6969
+ await this.currentRuntime.clear();
6970
+ await this.clearActivePreviewPorts();
6971
+ try {
6972
+ await pruneTunnelsForRestart(this.ctx.storage);
6973
+ } catch (error) {
6974
+ this.logger.error("Failed to reconcile tunnel storage after container stop", error instanceof Error ? error : new Error(String(error)));
6975
+ }
6004
6976
  this.client.disconnect();
6005
6977
  let hadR2EgressMount = false;
6006
6978
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
@@ -6222,6 +7194,99 @@ var Sandbox = class Sandbox extends Container {
6222
7194
  await super.onActivityExpired();
6223
7195
  }
6224
7196
  }
7197
+ isPreviewProxyRequest(request) {
7198
+ return request.headers.get(PREVIEW_PROXY_HEADER) === "1";
7199
+ }
7200
+ invalidPreviewTokenResponse() {
7201
+ return new Response(JSON.stringify({
7202
+ error: "Access denied: Invalid token or port not exposed",
7203
+ code: "INVALID_TOKEN"
7204
+ }), {
7205
+ status: 404,
7206
+ headers: { "Content-Type": "application/json" }
7207
+ });
7208
+ }
7209
+ stalePreviewURLResponse() {
7210
+ return new Response(JSON.stringify({
7211
+ error: "Preview URL is stale because the sandbox runtime is not active",
7212
+ code: "STALE_PREVIEW_URL"
7213
+ }), {
7214
+ status: 410,
7215
+ headers: { "Content-Type": "application/json" }
7216
+ });
7217
+ }
7218
+ getPreviewForwardingContainer() {
7219
+ return this.ctx.container;
7220
+ }
7221
+ beginPreviewForward() {
7222
+ const lifecycle = this;
7223
+ lifecycle.inflightRequests = (lifecycle.inflightRequests ?? 0) + 1;
7224
+ this.renewActivityTimeout();
7225
+ let settled = false;
7226
+ return () => {
7227
+ if (settled) return;
7228
+ settled = true;
7229
+ lifecycle.inflightRequests = Math.max(0, (lifecycle.inflightRequests ?? 0) - 1);
7230
+ if (lifecycle.inflightRequests === 0) this.renewActivityTimeout();
7231
+ };
7232
+ }
7233
+ async fetchPreviewIfRunning(request, port, runtime) {
7234
+ const container = this.getPreviewForwardingContainer();
7235
+ const state = await this.getState();
7236
+ if (!container?.running || state.status !== "healthy") return this.stalePreviewURLResponse();
7237
+ if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
7238
+ const result = await forwardPreviewRequest(container.getTcpPort(port), request, {
7239
+ beginForward: () => this.beginPreviewForward(),
7240
+ renewActivity: () => this.renewActivityTimeout()
7241
+ });
7242
+ if (result.status === "network-lost") {
7243
+ if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
7244
+ return new Response("Container suddenly disconnected, try again", { status: 500 });
7245
+ }
7246
+ return result.response;
7247
+ }
7248
+ buildPreviewProxyRequest(request, port, sandboxId) {
7249
+ const url = new URL(request.url);
7250
+ const proxyUrl = `http://localhost:${port}${url.pathname}${url.search}`;
7251
+ const headers = new Headers(request.headers);
7252
+ for (const header of PREVIEW_PROXY_HEADERS) headers.delete(header);
7253
+ headers.set("X-Original-URL", request.url);
7254
+ headers.set("X-Forwarded-Host", url.hostname);
7255
+ headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
7256
+ headers.set("X-Sandbox-Name", this.sandboxName ?? sandboxId);
7257
+ if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return new Request(request, {
7258
+ headers,
7259
+ redirect: "manual"
7260
+ });
7261
+ return new Request(proxyUrl, {
7262
+ method: request.method,
7263
+ headers,
7264
+ body: request.body,
7265
+ duplex: "half",
7266
+ redirect: "manual"
7267
+ });
7268
+ }
7269
+ async proxyPreviewRequest(request) {
7270
+ const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER);
7271
+ const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER);
7272
+ const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER);
7273
+ const port = portValue === null ? NaN : Number.parseInt(portValue, 10);
7274
+ if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) return this.invalidPreviewTokenResponse();
7275
+ const proxyRequest = this.buildPreviewProxyRequest(request, port, sandboxId);
7276
+ const validation = await this.validatePreviewURLForRuntime(port, token);
7277
+ if (validation.status === "invalid") return this.invalidPreviewTokenResponse();
7278
+ if (validation.status === "stale") {
7279
+ this.logger.warn("Stale preview URL blocked", {
7280
+ port,
7281
+ sandboxId,
7282
+ containerStatus: validation.containerStatus,
7283
+ reason: validation.reason,
7284
+ method: request.method
7285
+ });
7286
+ return this.stalePreviewURLResponse();
7287
+ }
7288
+ return await this.fetchPreviewIfRunning(proxyRequest, port, validation.runtime);
7289
+ }
6225
7290
  async fetch(request) {
6226
7291
  const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate();
6227
7292
  const requestLogger = this.logger.child({
@@ -6229,6 +7294,7 @@ var Sandbox = class Sandbox extends Container {
6229
7294
  operation: "fetch"
6230
7295
  });
6231
7296
  const url = new URL(request.url);
7297
+ if (this.isPreviewProxyRequest(request)) return await this.proxyPreviewRequest(request);
6232
7298
  if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
6233
7299
  const name = request.headers.get("X-Sandbox-Name");
6234
7300
  this.sandboxName = name;
@@ -6320,10 +7386,78 @@ var Sandbox = class Sandbox extends Container {
6320
7386
  if (containerPlacementId === void 0) return;
6321
7387
  await this.ctx.storage.put("containerPlacementId", containerPlacementId);
6322
7388
  }
7389
+ async resolveExecution(explicitSessionId) {
7390
+ if (explicitSessionId !== void 0) {
7391
+ this.validateExplicitSessionId(explicitSessionId);
7392
+ if (explicitSessionId === DISABLE_SESSION_TOKEN) return { kind: "sessionless" };
7393
+ return {
7394
+ kind: "session",
7395
+ sessionId: explicitSessionId
7396
+ };
7397
+ }
7398
+ return {
7399
+ kind: "session",
7400
+ sessionId: await this.ensureDefaultSession()
7401
+ };
7402
+ }
7403
+ validateExplicitSessionId(sessionId) {
7404
+ if (sessionId.trim().length === 0) throw new Error("sessionId must not be empty or whitespace");
7405
+ }
7406
+ serializeExecutionContext(context) {
7407
+ if (context.kind === "sessionless") return DISABLE_SESSION_TOKEN;
7408
+ return context.sessionId;
7409
+ }
7410
+ getPublicExecutionSessionId(sessionId) {
7411
+ return sessionId === DISABLE_SESSION_TOKEN ? void 0 : sessionId;
7412
+ }
7413
+ /**
7414
+ * Resolves the session ID to annotate returned Process objects.
7415
+ *
7416
+ * Unlike `resolveExecution`, this is synchronous and never creates a
7417
+ * session. When the default session hasn't been established yet, it returns
7418
+ * `undefined` rather than triggering session creation. The resolved value is
7419
+ * only used to populate `Process.sessionId` on the returned object — it is
7420
+ * never sent to the container API.
7421
+ */
7422
+ getProcessSessionBinding(explicitSessionId) {
7423
+ if (explicitSessionId !== void 0) {
7424
+ this.validateExplicitSessionId(explicitSessionId);
7425
+ if (explicitSessionId === DISABLE_SESSION_TOKEN) return;
7426
+ return explicitSessionId;
7427
+ }
7428
+ return this.defaultSession ?? void 0;
7429
+ }
7430
+ resolveExecutionEnv(sessionId, env$1) {
7431
+ if (sessionId === DISABLE_SESSION_TOKEN) {
7432
+ const mergedEnv = filterEnvVars({
7433
+ ...this.envVars,
7434
+ ...env$1 ?? {}
7435
+ });
7436
+ return Object.keys(mergedEnv).length > 0 ? mergedEnv : void 0;
7437
+ }
7438
+ if (env$1 === void 0) return;
7439
+ const filteredEnv = filterEnvVars(env$1);
7440
+ return Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
7441
+ }
7442
+ buildExecutionRequestOptions(sessionId, options) {
7443
+ const env$1 = this.resolveExecutionEnv(sessionId, options?.env);
7444
+ if (options?.timeout === void 0 && env$1 === void 0 && options?.cwd === void 0 && options?.origin === void 0) return;
7445
+ return {
7446
+ ...options?.timeout !== void 0 && { timeoutMs: options.timeout },
7447
+ ...env$1 !== void 0 && { env: env$1 },
7448
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
7449
+ ...options?.origin !== void 0 && { origin: options.origin }
7450
+ };
7451
+ }
6323
7452
  async exec(command, options) {
6324
- const session = await this.ensureDefaultSession();
7453
+ const context = await this.resolveExecution();
7454
+ const session = this.serializeExecutionContext(context);
6325
7455
  return this.execWithSession(command, session, options);
6326
7456
  }
7457
+ async execWithSessionToken(command, sessionId, options) {
7458
+ this.validateExplicitSessionId(sessionId);
7459
+ return this.execWithSession(command, sessionId, options);
7460
+ }
6327
7461
  /**
6328
7462
  * Execute an infrastructure command (backup, mount, env setup, etc.)
6329
7463
  * tagged with origin: 'internal' so logging demotes it to debug level.
@@ -6346,15 +7480,11 @@ var Sandbox = class Sandbox extends Container {
6346
7480
  let result;
6347
7481
  if (options?.stream && options?.onOutput) result = await this.executeWithStreaming(command, sessionId, options, startTime, timestamp);
6348
7482
  else {
6349
- const commandOptions = options && (options.timeout !== void 0 || options.env !== void 0 || options.cwd !== void 0 || options.origin !== void 0) ? {
6350
- timeoutMs: options.timeout,
6351
- env: options.env,
6352
- cwd: options.cwd,
6353
- origin: options.origin
6354
- } : void 0;
7483
+ const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
6355
7484
  const response = await this.client.commands.execute(command, sessionId, commandOptions);
6356
7485
  const duration = Date.now() - startTime;
6357
- result = this.mapExecuteResponseToExecResult(response, duration, sessionId);
7486
+ const publicSessionId = this.getPublicExecutionSessionId(sessionId);
7487
+ result = this.mapExecuteResponseToExecResult(response, duration, publicSessionId);
6358
7488
  }
6359
7489
  execOutcome = {
6360
7490
  exitCode: result.exitCode,
@@ -6373,7 +7503,7 @@ var Sandbox = class Sandbox extends Container {
6373
7503
  command,
6374
7504
  exitCode: execOutcome?.exitCode,
6375
7505
  durationMs: Date.now() - startTime,
6376
- sessionId,
7506
+ sessionId: this.getPublicExecutionSessionId(sessionId),
6377
7507
  origin: options?.origin ?? "user",
6378
7508
  error: execError ?? void 0,
6379
7509
  errorMessage: execError?.message
@@ -6384,12 +7514,8 @@ var Sandbox = class Sandbox extends Container {
6384
7514
  let stdout = "";
6385
7515
  let stderr = "";
6386
7516
  try {
6387
- const stream = await this.client.commands.executeStream(command, sessionId, {
6388
- timeoutMs: options.timeout,
6389
- env: options.env,
6390
- cwd: options.cwd,
6391
- origin: options.origin
6392
- });
7517
+ const commandOptions = this.buildExecutionRequestOptions(sessionId, options);
7518
+ const stream = await this.client.commands.executeStream(command, sessionId, commandOptions);
6393
7519
  for await (const event of parseSSEStream(stream)) {
6394
7520
  if (options.signal?.aborted) throw new Error("Operation was aborted");
6395
7521
  switch (event.type) {
@@ -6411,7 +7537,7 @@ var Sandbox = class Sandbox extends Container {
6411
7537
  command,
6412
7538
  duration,
6413
7539
  timestamp,
6414
- sessionId
7540
+ sessionId: this.getPublicExecutionSessionId(sessionId)
6415
7541
  };
6416
7542
  }
6417
7543
  case "error": throw new Error(event.data || "Command execution failed");
@@ -6687,12 +7813,16 @@ var Sandbox = class Sandbox extends Container {
6687
7813
  }
6688
7814
  async startProcess(command, options, sessionId) {
6689
7815
  try {
6690
- const session = sessionId ?? await this.ensureDefaultSession();
7816
+ const execution = await this.resolveExecution(sessionId);
7817
+ const session = this.serializeExecutionContext(execution);
7818
+ const processSession = this.getProcessSessionBinding(session);
6691
7819
  const requestOptions = {
7820
+ ...this.buildExecutionRequestOptions(session, {
7821
+ timeout: options?.timeout,
7822
+ env: options?.env,
7823
+ cwd: options?.cwd
7824
+ }),
6692
7825
  ...options?.processId !== void 0 && { processId: options.processId },
6693
- ...options?.timeout !== void 0 && { timeoutMs: options.timeout },
6694
- ...options?.env !== void 0 && { env: filterEnvVars(options.env) },
6695
- ...options?.cwd !== void 0 && { cwd: options.cwd },
6696
7826
  ...options?.encoding !== void 0 && { encoding: options.encoding },
6697
7827
  ...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
6698
7828
  };
@@ -6705,7 +7835,7 @@ var Sandbox = class Sandbox extends Container {
6705
7835
  startTime: /* @__PURE__ */ new Date(),
6706
7836
  endTime: void 0,
6707
7837
  exitCode: void 0
6708
- }, session);
7838
+ }, processSession);
6709
7839
  if (options?.onStart) options.onStart(processObj);
6710
7840
  if (options?.onOutput || options?.onExit) this.startProcessCallbackStream(response.processId, options).catch(() => {});
6711
7841
  return processObj;
@@ -6733,13 +7863,14 @@ var Sandbox = class Sandbox extends Container {
6733
7863
  if (options.onExit) options.onExit(event.exitCode ?? null);
6734
7864
  return;
6735
7865
  }
7866
+ throw new Error("Stream ended without completion event");
6736
7867
  } catch (error) {
6737
7868
  if (options.onError && error instanceof Error) options.onError(error);
6738
7869
  this.logger.error("Background process streaming failed", error instanceof Error ? error : new Error(String(error)), { processId });
6739
7870
  }
6740
7871
  }
6741
7872
  async listProcesses(sessionId) {
6742
- const session = sessionId ?? await this.ensureDefaultSession();
7873
+ const session = this.getProcessSessionBinding(sessionId);
6743
7874
  return (await this.client.processes.listProcesses()).processes.map((processData) => this.createProcessFromDTO({
6744
7875
  id: processData.id,
6745
7876
  pid: processData.pid,
@@ -6751,22 +7882,24 @@ var Sandbox = class Sandbox extends Container {
6751
7882
  }, session));
6752
7883
  }
6753
7884
  async getProcess(id, sessionId) {
6754
- const session = sessionId ?? await this.ensureDefaultSession();
6755
- const response = await this.client.processes.getProcess(id).catch((e) => {
6756
- if (e instanceof ProcessNotFoundError) return null;
6757
- throw e;
6758
- });
6759
- if (!response?.process) return null;
6760
- const processData = response.process;
6761
- return this.createProcessFromDTO({
6762
- id: processData.id,
6763
- pid: processData.pid,
6764
- command: processData.command,
6765
- status: processData.status,
6766
- startTime: processData.startTime,
6767
- endTime: processData.endTime,
6768
- exitCode: processData.exitCode
6769
- }, session);
7885
+ const session = this.getProcessSessionBinding(sessionId);
7886
+ try {
7887
+ const response = await this.client.processes.getProcess(id);
7888
+ if (!response.process) return null;
7889
+ const processData = response.process;
7890
+ return this.createProcessFromDTO({
7891
+ id: processData.id,
7892
+ pid: processData.pid,
7893
+ command: processData.command,
7894
+ status: processData.status,
7895
+ startTime: processData.startTime,
7896
+ endTime: processData.endTime,
7897
+ exitCode: processData.exitCode
7898
+ }, session);
7899
+ } catch (error) {
7900
+ if (error instanceof ProcessNotFoundError) return null;
7901
+ throw error;
7902
+ }
6770
7903
  }
6771
7904
  async killProcess(id, signal, sessionId) {
6772
7905
  await this.client.processes.killProcess(id);
@@ -6787,23 +7920,29 @@ var Sandbox = class Sandbox extends Container {
6787
7920
  }
6788
7921
  async execStream(command, options) {
6789
7922
  if (options?.signal?.aborted) throw new Error("Operation was aborted");
6790
- const session = await this.ensureDefaultSession();
6791
- return this.client.commands.executeStream(command, session, {
6792
- timeoutMs: options?.timeout,
7923
+ const context = await this.resolveExecution(options?.sessionId);
7924
+ const session = this.serializeExecutionContext(context);
7925
+ const executionOptions = this.buildExecutionRequestOptions(session, {
7926
+ timeout: options?.timeout,
6793
7927
  env: options?.env,
6794
7928
  cwd: options?.cwd
6795
7929
  });
7930
+ return this.client.commands.executeStream(command, session, executionOptions);
7931
+ }
7932
+ async execStreamWithSessionToken(command, sessionId, options) {
7933
+ this.validateExplicitSessionId(sessionId);
7934
+ return this.execStreamWithSession(command, sessionId, options);
6796
7935
  }
6797
7936
  /**
6798
7937
  * Internal session-aware execStream implementation
6799
7938
  */
6800
7939
  async execStreamWithSession(command, sessionId, options) {
6801
7940
  if (options?.signal?.aborted) throw new Error("Operation was aborted");
6802
- return this.client.commands.executeStream(command, sessionId, {
6803
- timeoutMs: options?.timeout,
7941
+ return this.client.commands.executeStream(command, sessionId, this.buildExecutionRequestOptions(sessionId, {
7942
+ timeout: options?.timeout,
6804
7943
  env: options?.env,
6805
7944
  cwd: options?.cwd
6806
- });
7945
+ }));
6807
7946
  }
6808
7947
  /**
6809
7948
  * Stream logs from a background process as a ReadableStream.
@@ -6813,7 +7952,8 @@ var Sandbox = class Sandbox extends Container {
6813
7952
  return this.client.processes.streamProcessLogs(processId);
6814
7953
  }
6815
7954
  async gitCheckout(repoUrl, options) {
6816
- const session = options?.sessionId ?? await this.ensureDefaultSession();
7955
+ const execution = await this.resolveExecution(options?.sessionId);
7956
+ const session = this.serializeExecutionContext(execution);
6817
7957
  return this.client.git.checkout(repoUrl, session, {
6818
7958
  branch: options?.branch,
6819
7959
  targetDir: options?.targetDir,
@@ -6822,28 +7962,34 @@ var Sandbox = class Sandbox extends Container {
6822
7962
  });
6823
7963
  }
6824
7964
  async mkdir(path$1, options = {}) {
6825
- const session = options.sessionId ?? await this.ensureDefaultSession();
7965
+ const execution = await this.resolveExecution(options.sessionId);
7966
+ const session = this.serializeExecutionContext(execution);
6826
7967
  return this.client.files.mkdir(path$1, session, { recursive: options.recursive });
6827
7968
  }
6828
7969
  async writeFile(path$1, content, options = {}) {
6829
- const session = options.sessionId ?? await this.ensureDefaultSession();
7970
+ const execution = await this.resolveExecution(options.sessionId);
7971
+ const session = this.serializeExecutionContext(execution);
6830
7972
  if (content instanceof ReadableStream) return this.client.files.writeFileStream(path$1, content, session);
6831
7973
  return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
6832
7974
  }
6833
7975
  async deleteFile(path$1, sessionId) {
6834
- const session = sessionId ?? await this.ensureDefaultSession();
7976
+ const execution = await this.resolveExecution(sessionId);
7977
+ const session = this.serializeExecutionContext(execution);
6835
7978
  return this.client.files.deleteFile(path$1, session);
6836
7979
  }
6837
7980
  async renameFile(oldPath, newPath, sessionId) {
6838
- const session = sessionId ?? await this.ensureDefaultSession();
7981
+ const execution = await this.resolveExecution(sessionId);
7982
+ const session = this.serializeExecutionContext(execution);
6839
7983
  return this.client.files.renameFile(oldPath, newPath, session);
6840
7984
  }
6841
7985
  async moveFile(sourcePath, destinationPath, sessionId) {
6842
- const session = sessionId ?? await this.ensureDefaultSession();
7986
+ const execution = await this.resolveExecution(sessionId);
7987
+ const session = this.serializeExecutionContext(execution);
6843
7988
  return this.client.files.moveFile(sourcePath, destinationPath, session);
6844
7989
  }
6845
7990
  async readFile(path$1, options = {}) {
6846
- const session = options.sessionId ?? await this.ensureDefaultSession();
7991
+ const execution = await this.resolveExecution(options.sessionId);
7992
+ const session = this.serializeExecutionContext(execution);
6847
7993
  if (options.encoding === "none") return this.client.files.readFile(path$1, session, { encoding: "none" });
6848
7994
  return this.client.files.readFile(path$1, session, { encoding: options.encoding });
6849
7995
  }
@@ -6854,15 +8000,18 @@ var Sandbox = class Sandbox extends Container {
6854
8000
  * @param options - Optional session ID
6855
8001
  */
6856
8002
  async readFileStream(path$1, options = {}) {
6857
- const session = options.sessionId ?? await this.ensureDefaultSession();
8003
+ const execution = await this.resolveExecution(options.sessionId);
8004
+ const session = this.serializeExecutionContext(execution);
6858
8005
  return this.client.files.readFileStream(path$1, session);
6859
8006
  }
6860
8007
  async listFiles(path$1, options) {
6861
- const session = await this.ensureDefaultSession();
8008
+ const context = await this.resolveExecution(options?.sessionId);
8009
+ const session = this.serializeExecutionContext(context);
6862
8010
  return this.client.files.listFiles(path$1, session, options);
6863
8011
  }
6864
8012
  async exists(path$1, sessionId) {
6865
- const session = sessionId ?? await this.ensureDefaultSession();
8013
+ const execution = await this.resolveExecution(sessionId);
8014
+ const session = this.serializeExecutionContext(execution);
6866
8015
  return this.client.files.exists(path$1, session);
6867
8016
  }
6868
8017
  /**
@@ -6879,17 +8028,10 @@ var Sandbox = class Sandbox extends Container {
6879
8028
  */
6880
8029
  async getDesktopStreamUrl(hostname, options) {
6881
8030
  if ((await this.client.desktop.status()).status === "inactive") throw new Error("Desktop is not running. Call sandbox.desktop.start() first.");
6882
- let url;
6883
- try {
6884
- url = (await this.exposePort(6080, {
6885
- hostname,
6886
- token: options?.token
6887
- })).url;
6888
- } catch {
6889
- const existingEntry = (await this.readPortTokens())["6080"];
6890
- if (existingEntry && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingEntry.token);
6891
- else throw new Error("Failed to get desktop stream URL: port 6080 could not be exposed and no existing token found.");
6892
- }
8031
+ const url = (await this.exposePort(6080, {
8032
+ hostname,
8033
+ token: options?.token
8034
+ })).url;
6893
8035
  try {
6894
8036
  await this.waitForPort({
6895
8037
  portToCheck: 6080,
@@ -6913,7 +8055,8 @@ var Sandbox = class Sandbox extends Container {
6913
8055
  * @param options - Watch options
6914
8056
  */
6915
8057
  async watch(path$1, options = {}) {
6916
- const sessionId = options.sessionId ?? await this.ensureDefaultSession();
8058
+ const execution = await this.resolveExecution(options.sessionId);
8059
+ const sessionId = this.serializeExecutionContext(execution);
6917
8060
  return this.client.watch.watch({
6918
8061
  path: path$1,
6919
8062
  recursive: options.recursive,
@@ -6933,7 +8076,8 @@ var Sandbox = class Sandbox extends Container {
6933
8076
  * @param options - Change-check options
6934
8077
  */
6935
8078
  async checkChanges(path$1, options = {}) {
6936
- const sessionId = options.sessionId ?? await this.ensureDefaultSession();
8079
+ const execution = await this.resolveExecution(options.sessionId);
8080
+ const sessionId = this.serializeExecutionContext(execution);
6937
8081
  return this.client.watch.checkChanges({
6938
8082
  path: path$1,
6939
8083
  recursive: options.recursive,
@@ -6946,11 +8090,10 @@ var Sandbox = class Sandbox extends Container {
6946
8090
  /**
6947
8091
  * Expose a port and get a preview URL for accessing services running in the sandbox
6948
8092
  *
6949
- * Preview URLs survive transient container restarts: the token and any
6950
- * friendly name are persisted in Durable Object storage, and the port is
6951
- * automatically re-exposed on the container when it comes back up. Tokens
6952
- * are cleared only on explicit `unexposePort()` or full sandbox
6953
- * `destroy()`.
8093
+ * Preview URL authorization survives transient container restarts, but
8094
+ * forwarding is active only for the runtime where `exposePort()` was last
8095
+ * called. Call `exposePort()` again after a restart to reactivate an
8096
+ * existing URL for the current runtime.
6954
8097
  *
6955
8098
  * @param port - Port number to expose (1024-65535)
6956
8099
  * @param options - Configuration options
@@ -6987,27 +8130,33 @@ var Sandbox = class Sandbox extends Container {
6987
8130
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6988
8131
  });
6989
8132
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
6990
- let token;
6991
- if (options.token !== void 0) {
6992
- this.validateCustomToken(options.token);
6993
- token = options.token;
6994
- } else token = this.generatePortToken();
6995
- const tokens = await this.readPortTokens();
6996
- const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
6997
- if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
6998
- const sessionId = await this.ensureDefaultSession();
6999
- await this.client.ports.exposePort(port, sessionId, options?.name);
7000
- tokens[port.toString()] = {
7001
- token,
7002
- name: options?.name
7003
- };
7004
- await this.ctx.storage.put("portTokens", tokens);
8133
+ if (options.token !== void 0) this.validateCustomToken(options.token);
8134
+ await this.ensureDefaultSession();
8135
+ let runtime = await this.currentRuntime.get();
8136
+ runtime = runtime ?? await this.currentRuntime.markStarted();
8137
+ await this.currentRuntime.assertActive(runtime);
8138
+ const token = await this.ctx.storage.transaction(async (txn) => {
8139
+ const tokens = await this.readPortTokens(txn);
8140
+ const existingEntry = tokens[port.toString()];
8141
+ const nextToken = options.token ?? existingEntry?.token ?? this.generatePortToken();
8142
+ const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === nextToken && p !== port.toString());
8143
+ if (existingPort) throw new SandboxSecurityError(`Token '${nextToken}' is already in use by port ${existingPort[0]}. Please use a different token.`);
8144
+ const activations = await this.readActivePreviewPorts(txn);
8145
+ tokens[port.toString()] = {
8146
+ token: nextToken,
8147
+ name: options.name
8148
+ };
8149
+ activations[port.toString()] = runtime.scope({ token: nextToken });
8150
+ await Promise.all([txn.put(PORT_TOKENS_STORAGE_KEY, tokens), this.writeActivePreviewPorts(activations, txn)]);
8151
+ return nextToken;
8152
+ });
8153
+ await this.currentRuntime.assertActive(runtime);
7005
8154
  const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
7006
8155
  outcome = "success";
7007
8156
  return {
7008
8157
  url,
7009
8158
  port,
7010
- name: options?.name
8159
+ name: options.name
7011
8160
  };
7012
8161
  } catch (error) {
7013
8162
  caughtError = error instanceof Error ? error : new Error(String(error));
@@ -7018,29 +8167,37 @@ var Sandbox = class Sandbox extends Container {
7018
8167
  outcome,
7019
8168
  port,
7020
8169
  durationMs: Date.now() - exposeStartTime,
7021
- name: options?.name,
8170
+ name: options.name,
7022
8171
  hostname: options.hostname,
7023
8172
  error: caughtError
7024
8173
  });
7025
8174
  }
7026
8175
  }
8176
+ /**
8177
+ * Revoke preview URL authorization and current-runtime activation for a port.
8178
+ *
8179
+ * Revocation is idempotent: calling this for a port with no preview state is
8180
+ * still successful. The operation clears Durable Object-owned preview state
8181
+ * only and does not contact, probe, wake, or clean up the container runtime.
8182
+ */
7027
8183
  async unexposePort(port) {
7028
8184
  const unexposeStartTime = Date.now();
7029
8185
  let outcome = "error";
7030
8186
  let caughtError;
7031
8187
  try {
7032
8188
  if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
7033
- const tokens = await this.readPortTokens();
7034
- if (tokens[port.toString()]) {
7035
- delete tokens[port.toString()];
7036
- await this.ctx.storage.put("portTokens", tokens);
7037
- }
7038
- const sessionId = await this.ensureDefaultSession();
7039
- try {
7040
- await this.client.ports.unexposePort(port, sessionId);
7041
- } catch (error) {
7042
- if (!(error instanceof PortNotExposedError)) throw error;
7043
- }
8189
+ await this.ctx.storage.transaction(async (txn) => {
8190
+ const tokens = await this.readPortTokens(txn);
8191
+ if (tokens[port.toString()]) {
8192
+ delete tokens[port.toString()];
8193
+ await txn.put(PORT_TOKENS_STORAGE_KEY, tokens);
8194
+ }
8195
+ const activations = await this.readActivePreviewPorts(txn);
8196
+ if (activations[port.toString()]) {
8197
+ delete activations[port.toString()];
8198
+ await this.writeActivePreviewPorts(activations, txn);
8199
+ }
8200
+ });
7044
8201
  outcome = "success";
7045
8202
  } catch (error) {
7046
8203
  caughtError = error instanceof Error ? error : new Error(String(error));
@@ -7055,23 +8212,17 @@ var Sandbox = class Sandbox extends Container {
7055
8212
  });
7056
8213
  }
7057
8214
  }
8215
+ /**
8216
+ * Returns preview URLs that are currently forwardable in the active runtime.
8217
+ * Durable authorization without current-runtime activation is omitted.
8218
+ */
7058
8219
  async getExposedPorts(hostname) {
7059
- const sessionId = await this.ensureDefaultSession();
7060
- const response = await this.client.ports.getExposedPorts(sessionId);
7061
8220
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
7062
- const tokens = await this.readPortTokens();
7063
- return response.ports.flatMap((port) => {
7064
- const entry = tokens[port.port.toString()];
7065
- if (!entry) {
7066
- this.logger.warn("Port exposed on container but no token in storage; omitting from preview URL list", { port: port.port });
7067
- return [];
7068
- }
7069
- return [{
7070
- url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, entry.token),
7071
- port: port.port,
7072
- status: port.status
7073
- }];
7074
- });
8221
+ return (await this.getCurrentPreviewPorts()).map(({ port, entry }) => ({
8222
+ url: this.constructPreviewUrl(port, this.sandboxName, hostname, entry.token),
8223
+ port,
8224
+ status: "active"
8225
+ }));
7075
8226
  }
7076
8227
  /**
7077
8228
  * Namespaced tunnel API. Quick tunnels are zero-config preview URLs
@@ -7107,26 +8258,172 @@ var Sandbox = class Sandbox extends Container {
7107
8258
  const built = createTunnelsHandler({
7108
8259
  client: this.client,
7109
8260
  storage: this.ctx.storage,
7110
- logger: this.logger
8261
+ logger: this.logger,
8262
+ sandboxId: this.ctx.id.toString(),
8263
+ getNamedTunnelConfig: async () => {
8264
+ const envObj = this.env;
8265
+ const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
8266
+ if (!token) throw new Error("Named tunnels require CLOUDFLARE_API_TOKEN. Set it as a secret in your wrangler.jsonc.");
8267
+ const accountId = await this.getTunnelAccountId();
8268
+ return {
8269
+ token,
8270
+ accountId,
8271
+ zoneId: await this.getTunnelZoneId(token, accountId)
8272
+ };
8273
+ }
7111
8274
  });
7112
8275
  this.tunnelsHandler = built.tunnels;
7113
8276
  this.tunnelExitHandler = built.handleTunnelExit;
8277
+ this.destroyAllTunnels = built.destroyAll;
7114
8278
  }
7115
- async isPortExposed(port) {
7116
- try {
7117
- const sessionId = await this.ensureDefaultSession();
7118
- return (await this.client.ports.getExposedPorts(sessionId)).ports.some((exposedPort) => exposedPort.port === port);
7119
- } catch (error) {
7120
- this.logger.error("Error checking if port is exposed", error instanceof Error ? error : new Error(String(error)), { port });
7121
- return false;
8279
+ /**
8280
+ * Resolve the Cloudflare account id used for named-tunnel provisioning.
8281
+ *
8282
+ * Memoised for the lifetime of this DO instance. The first call may hit
8283
+ * `GET /user/tokens/verify` to derive the account id from the configured
8284
+ * `CLOUDFLARE_API_TOKEN`; subsequent calls return the cached promise.
8285
+ *
8286
+ * Only successful resolutions are cached: a rejected lookup clears the
8287
+ * slot so the next caller retries. Otherwise a transient failure on
8288
+ * first use would permanently poison every later named-tunnel `get()`
8289
+ * on this DO instance.
8290
+ */
8291
+ getTunnelAccountId() {
8292
+ if (!this.tunnelAccountIdPromise) {
8293
+ const pending = resolveAccountId(this.env, { overrideKey: "CLOUDFLARE_TUNNEL_ACCOUNT_ID" });
8294
+ this.tunnelAccountIdPromise = pending;
8295
+ pending.catch(() => {
8296
+ if (this.tunnelAccountIdPromise === pending) this.tunnelAccountIdPromise = null;
8297
+ });
7122
8298
  }
8299
+ return this.tunnelAccountIdPromise;
7123
8300
  }
8301
+ /**
8302
+ * Resolve the Cloudflare zone id used for named-tunnel provisioning.
8303
+ *
8304
+ * Memoised for the lifetime of this DO instance. Falls back to the
8305
+ * single zone the token can see under `accountId` via `GET /zones`
8306
+ * when `CLOUDFLARE_ZONE_ID` is not set. Failed lookups clear the cache
8307
+ * so the next caller retries — see `getTunnelAccountId` for the
8308
+ * rationale.
8309
+ */
8310
+ getTunnelZoneId(token, accountId) {
8311
+ if (!this.tunnelZoneIdPromise) {
8312
+ const pending = resolveZoneId(this.env, {
8313
+ token,
8314
+ accountId
8315
+ });
8316
+ this.tunnelZoneIdPromise = pending;
8317
+ pending.catch(() => {
8318
+ if (this.tunnelZoneIdPromise === pending) this.tunnelZoneIdPromise = null;
8319
+ });
8320
+ }
8321
+ return this.tunnelZoneIdPromise;
8322
+ }
8323
+ /**
8324
+ * Returns whether a port is currently preview-forwardable.
8325
+ * This checks Durable Object-owned auth and runtime activation without
8326
+ * contacting or waking the container.
8327
+ */
8328
+ async isPortExposed(port) {
8329
+ if (!validatePort(port)) return false;
8330
+ return (await this.getCurrentPreviewPorts()).some((activePort) => activePort.port === port);
8331
+ }
8332
+ /**
8333
+ * Checks durable preview URL authorization for a port/token pair.
8334
+ *
8335
+ * This does not check whether the port is activated for the current runtime
8336
+ * and is not sufficient to decide whether preview traffic may forward.
8337
+ */
7124
8338
  async validatePortToken(port, token) {
7125
8339
  const entry = (await this.readPortTokens())[port.toString()];
7126
8340
  if (!entry) return false;
8341
+ return this.previewTokensMatch(entry.token, token);
8342
+ }
8343
+ async validatePreviewURLForRuntime(port, token) {
8344
+ const containerState = await this.getState();
8345
+ const containerRunning = this.ctx.container?.running === true;
8346
+ const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
8347
+ const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
8348
+ return {
8349
+ ...previewState,
8350
+ runtime: runtime$1
8351
+ };
8352
+ });
8353
+ const entry = tokens[port.toString()];
8354
+ if (!entry) return { status: "invalid" };
8355
+ if (!this.previewTokensMatch(entry.token, token)) return { status: "invalid" };
8356
+ if (containerState.status !== "healthy") return {
8357
+ status: "stale",
8358
+ reason: "runtime-not-healthy",
8359
+ containerStatus: containerState.status
8360
+ };
8361
+ if (!containerRunning) return {
8362
+ status: "stale",
8363
+ reason: "runtime-not-running",
8364
+ containerStatus: containerState.status
8365
+ };
8366
+ if (!runtime) return {
8367
+ status: "stale",
8368
+ reason: "missing-runtime-id",
8369
+ containerStatus: containerState.status
8370
+ };
8371
+ const activation = activations[port.toString()];
8372
+ if (!activation) return {
8373
+ status: "stale",
8374
+ reason: "missing-activation",
8375
+ containerStatus: containerState.status
8376
+ };
8377
+ if (!runtime.owns(activation)) return {
8378
+ status: "stale",
8379
+ reason: "runtime-mismatch",
8380
+ containerStatus: containerState.status
8381
+ };
8382
+ if (!this.previewTokensMatch(activation.token, token)) {
8383
+ this.logger.warn("Preview URL activation token mismatch", {
8384
+ port,
8385
+ runtimeIdentityID: runtime.id
8386
+ });
8387
+ return {
8388
+ status: "stale",
8389
+ reason: "token-mismatch",
8390
+ containerStatus: containerState.status
8391
+ };
8392
+ }
8393
+ return {
8394
+ status: "active",
8395
+ runtime
8396
+ };
8397
+ }
8398
+ async getCurrentPreviewPorts() {
8399
+ const containerState = await this.getState();
8400
+ const containerRunning = this.ctx.container?.running === true;
8401
+ const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
8402
+ const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
8403
+ return {
8404
+ ...previewState,
8405
+ runtime: runtime$1
8406
+ };
8407
+ });
8408
+ if (containerState.status !== "healthy" || !containerRunning || !runtime) return [];
8409
+ const activePorts = [];
8410
+ for (const [portKey, activation] of Object.entries(activations)) {
8411
+ const port = Number.parseInt(portKey, 10);
8412
+ const entry = tokens[portKey];
8413
+ if (!entry || !Number.isInteger(port) || !validatePort(port)) continue;
8414
+ if (!runtime.owns(activation)) continue;
8415
+ if (!this.previewTokensMatch(entry.token, activation.token)) continue;
8416
+ activePorts.push({
8417
+ port,
8418
+ entry
8419
+ });
8420
+ }
8421
+ return activePorts.sort((a, b) => a.port - b.port);
8422
+ }
8423
+ previewTokensMatch(expected, actual) {
7127
8424
  const encoder = new TextEncoder();
7128
- const a = encoder.encode(entry.token);
7129
- const b = encoder.encode(token);
8425
+ const a = encoder.encode(expected);
8426
+ const b = encoder.encode(actual);
7130
8427
  try {
7131
8428
  return crypto.subtle.timingSafeEqual(a, b);
7132
8429
  } catch {
@@ -7174,6 +8471,7 @@ var Sandbox = class Sandbox extends Container {
7174
8471
  */
7175
8472
  async createSession(options) {
7176
8473
  const sessionId = options?.id || `session-${Date.now()}`;
8474
+ if (sessionId === DISABLE_SESSION_TOKEN) throw new Error(`Session ID '${DISABLE_SESSION_TOKEN}' is reserved for internal use`);
7177
8475
  const filteredEnv = filterEnvVars({
7178
8476
  ...this.envVars,
7179
8477
  ...options?.env ?? {}
@@ -7457,10 +8755,10 @@ var Sandbox = class Sandbox extends Container {
7457
8755
  * Returns validated presigned URL configuration or throws if not configured.
7458
8756
  * All credential fields plus the R2 binding are required for backup to work.
7459
8757
  */
7460
- requirePresignedUrlSupport() {
8758
+ requirePresignedURLSupport() {
7461
8759
  if (!this.r2Client || !this.r2AccountId || !this.backupBucketName) {
7462
8760
  const missing = [];
7463
- if (!this.r2AccountId) missing.push("CLOUDFLARE_ACCOUNT_ID");
8761
+ if (!this.r2AccountId) missing.push("CLOUDFLARE_R2_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID");
7464
8762
  if (!this.r2AccessKeyId) missing.push("R2_ACCESS_KEY_ID");
7465
8763
  if (!this.r2SecretAccessKey) missing.push("R2_SECRET_ACCESS_KEY");
7466
8764
  if (!this.backupBucketName) missing.push("BACKUP_BUCKET_NAME");
@@ -7478,15 +8776,21 @@ var Sandbox = class Sandbox extends Container {
7478
8776
  bucketName: this.backupBucketName
7479
8777
  };
7480
8778
  }
8779
+ getBackupBucketEndpoint(accountId) {
8780
+ return this.backupBucketEndpoint ?? `https://${accountId}.r2.cloudflarestorage.com`;
8781
+ }
8782
+ getBackupObjectURL(accountId, bucketName, r2Key) {
8783
+ const encodedBucket = encodeURIComponent(bucketName);
8784
+ const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
8785
+ return new URL(`${this.getBackupBucketEndpoint(accountId)}/${encodedBucket}/${encodedKey}`);
8786
+ }
7481
8787
  /**
7482
8788
  * Generate a presigned GET URL for downloading an object from R2.
7483
8789
  * The container can curl this URL directly without credentials.
7484
8790
  */
7485
- async generatePresignedGetUrl(r2Key) {
7486
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7487
- const encodedBucket = encodeURIComponent(bucketName);
7488
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7489
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
8791
+ async generatePresignedGetURL(r2Key) {
8792
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
8793
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7490
8794
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7491
8795
  return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
7492
8796
  }
@@ -7494,11 +8798,9 @@ var Sandbox = class Sandbox extends Container {
7494
8798
  * Generate a presigned PUT URL for uploading an object to R2.
7495
8799
  * The container can curl PUT to this URL directly without credentials.
7496
8800
  */
7497
- async generatePresignedPutUrl(r2Key) {
7498
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7499
- const encodedBucket = encodeURIComponent(bucketName);
7500
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7501
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
8801
+ async generatePresignedPutURL(r2Key) {
8802
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
8803
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7502
8804
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7503
8805
  return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
7504
8806
  }
@@ -7508,7 +8810,7 @@ var Sandbox = class Sandbox extends Container {
7508
8810
  * ~24 MB/s throughput vs ~0.6 MB/s for base64 readFile.
7509
8811
  */
7510
8812
  async uploadBackupPresigned(archivePath, r2Key, archiveSize, backupId, dir, backupSession) {
7511
- const presignedUrl = await this.generatePresignedPutUrl(r2Key);
8813
+ const presignedURL = await this.generatePresignedPutURL(r2Key);
7512
8814
  const curlCmd = [
7513
8815
  "curl -sSf",
7514
8816
  "-X PUT",
@@ -7518,7 +8820,7 @@ var Sandbox = class Sandbox extends Container {
7518
8820
  "--retry 2",
7519
8821
  "--retry-max-time 60",
7520
8822
  `-T ${shellEscape(archivePath)}`,
7521
- shellEscape(presignedUrl)
8823
+ shellEscape(presignedURL)
7522
8824
  ].join(" ");
7523
8825
  const result = await this.execWithSession(curlCmd, backupSession, {
7524
8826
  timeout: 181e4,
@@ -7552,11 +8854,9 @@ var Sandbox = class Sandbox extends Container {
7552
8854
  /**
7553
8855
  * Generate a presigned PUT URL for a single part in a multipart upload.
7554
8856
  */
7555
- async generatePresignedPartUrl(r2Key, uploadId, partNumber) {
7556
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7557
- const encodedBucket = encodeURIComponent(bucketName);
7558
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7559
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
8857
+ async generatePresignedPartURL(r2Key, uploadId, partNumber) {
8858
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
8859
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7560
8860
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7561
8861
  url.searchParams.set("partNumber", String(partNumber));
7562
8862
  url.searchParams.set("uploadId", uploadId);
@@ -7571,9 +8871,9 @@ var Sandbox = class Sandbox extends Container {
7571
8871
  const targetParts = calculatePartCount(sizeBytes, BACKUP_MULTIPART_TARGET_PARTS, BACKUP_MULTIPART_MAX_PARTS);
7572
8872
  const numParts = Math.min(targetParts, Math.floor(sizeBytes / BACKUP_MULTIPART_MIN_PART_SIZE));
7573
8873
  if (numParts <= 1) return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
7574
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7575
- const objectUrl = `https://${accountId}.r2.cloudflarestorage.com/${encodeURIComponent(bucketName)}/${r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/")}`;
7576
- const createResp = await client.fetch(`${objectUrl}?uploads`, { method: "POST" });
8874
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
8875
+ const objectURL = this.getBackupObjectURL(accountId, bucketName, r2Key).toString();
8876
+ const createResp = await client.fetch(`${objectURL}?uploads`, { method: "POST" });
7577
8877
  if (!createResp.ok) throw new BackupCreateError({
7578
8878
  message: `Failed to initiate multipart upload: HTTP ${createResp.status}`,
7579
8879
  code: ErrorCode.BACKUP_CREATE_FAILED,
@@ -7596,7 +8896,7 @@ var Sandbox = class Sandbox extends Container {
7596
8896
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7597
8897
  });
7598
8898
  const abortMultipart = async () => {
7599
- await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
8899
+ await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
7600
8900
  };
7601
8901
  try {
7602
8902
  const partSize = Math.ceil(sizeBytes / numParts);
@@ -7607,7 +8907,7 @@ var Sandbox = class Sandbox extends Container {
7607
8907
  size: i === numParts - 1 ? sizeBytes - i * partSize : partSize
7608
8908
  })).map(async (part) => ({
7609
8909
  ...part,
7610
- url: await this.generatePresignedPartUrl(r2Key, uploadId, part.partNumber)
8910
+ url: await this.generatePresignedPartURL(r2Key, uploadId, part.partNumber)
7611
8911
  })));
7612
8912
  let uploadResult;
7613
8913
  try {
@@ -7638,7 +8938,7 @@ var Sandbox = class Sandbox extends Container {
7638
8938
  ...uploadResult.parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`),
7639
8939
  "</CompleteMultipartUpload>"
7640
8940
  ].join("");
7641
- const completeResp = await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, {
8941
+ const completeResp = await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, {
7642
8942
  method: "POST",
7643
8943
  headers: { "Content-Type": "application/xml" },
7644
8944
  body: completeXml
@@ -7680,7 +8980,7 @@ var Sandbox = class Sandbox extends Container {
7680
8980
  * with dd using byte offsets, then atomically moved to the final path.
7681
8981
  */
7682
8982
  async downloadBackupParallel(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
7683
- const presignedUrl = await this.generatePresignedGetUrl(r2Key);
8983
+ const presignedURL = await this.generatePresignedGetURL(r2Key);
7684
8984
  await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
7685
8985
  const tmpPath = `${archivePath}.tmp`;
7686
8986
  if (expectedSize < BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE) {
@@ -7691,7 +8991,7 @@ var Sandbox = class Sandbox extends Container {
7691
8991
  "--retry 2",
7692
8992
  "--retry-max-time 60",
7693
8993
  `-o ${shellEscape(tmpPath)}`,
7694
- shellEscape(presignedUrl)
8994
+ shellEscape(presignedURL)
7695
8995
  ].join(" ");
7696
8996
  const result = await this.execWithSession(curlCmd, backupSession, {
7697
8997
  timeout: 181e4,
@@ -7724,7 +9024,7 @@ var Sandbox = class Sandbox extends Container {
7724
9024
  "--connect-timeout 10",
7725
9025
  "--max-time 1800",
7726
9026
  `-H ${shellEscape(`Range: bytes=${range}`)}`,
7727
- shellEscape(presignedUrl),
9027
+ shellEscape(presignedURL),
7728
9028
  "|",
7729
9029
  "dd",
7730
9030
  `of=${shellEscape(tmpPath)}`,
@@ -7830,7 +9130,7 @@ var Sandbox = class Sandbox extends Container {
7830
9130
  }
7831
9131
  async doCreateBackup(options) {
7832
9132
  const bucket = this.requireBackupBucket();
7833
- this.requirePresignedUrlSupport();
9133
+ this.requirePresignedURLSupport();
7834
9134
  const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression, multipart = true } = options;
7835
9135
  const backupStartTime = Date.now();
7836
9136
  let backupId;
@@ -8130,7 +9430,7 @@ var Sandbox = class Sandbox extends Container {
8130
9430
  async doRestoreBackup(backup) {
8131
9431
  const restoreStartTime = Date.now();
8132
9432
  const bucket = this.requireBackupBucket();
8133
- this.requirePresignedUrlSupport();
9433
+ this.requirePresignedURLSupport();
8134
9434
  const { id, dir } = backup;
8135
9435
  let outcome = "error";
8136
9436
  let caughtError;
@@ -8400,5 +9700,5 @@ var Sandbox = class Sandbox extends Container {
8400
9700
  };
8401
9701
 
8402
9702
  //#endregion
8403
- 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 };
8404
- //# sourceMappingURL=sandbox-BcEq4aUF.js.map
9703
+ export { DesktopClient as A, DesktopProcessCrashedError as B, streamFile as C, PortClient as D, ProcessClient as E, BackupNotFoundError as F, ProcessReadyTimeoutError as G, DesktopUnavailableError as H, BackupRestoreError as I, RPCTransportError as K, DesktopInvalidCoordinatesError as L, BackupClient as M, BackupCreateError as N, GitClient as O, BackupExpiredError as P, DesktopInvalidOptionsError as R, collectFile as S, UtilityClient as T, InvalidBackupConfigError as U, DesktopStartFailedError as V, ProcessExitedBeforeReadyError as W, CodeInterpreter as _, PREVIEW_PROXY_HEADERS as a, validatePort as b, PREVIEW_PROXY_TOKEN_HEADER as c, InvalidMountConfigError as d, MissingCredentialsError as f, responseToAsyncIterable as g, parseSSEStream as h, PREVIEW_PROXY_HEADER as i, CommandClient as j, FileClient as k, BucketMountError as l, asyncIterableToSSEStream as m, getSandbox as n, PREVIEW_PROXY_PORT_HEADER as o, S3FSMountError as p, SessionTerminatedError as q, proxyTerminal as r, PREVIEW_PROXY_SANDBOX_ID_HEADER as s, Sandbox as t, BucketUnmountError as u, SandboxSecurityError as v, SandboxClient as w, validateTunnelName as x, sanitizeSandboxId as y, DesktopNotStartedError as z };
9704
+ //# sourceMappingURL=sandbox-DQxTkLyY.js.map